diff --git a/script/prepare-llvm-macos.sh b/script/prepare-llvm-macos.sh index f898fbf98e41..e8a72e6d86ca 100755 --- a/script/prepare-llvm-macos.sh +++ b/script/prepare-llvm-macos.sh @@ -19,7 +19,7 @@ else ln -s llvm llvm-host fi SDK=$(xcrun --show-sdk-path) -mkdir -p stage1/{bin,lib/libc,include/clang} +mkdir -p stage1/{bin,lib/libc,lib/frameworks,include/clang} CP="gcp -d" # preserve symlinks # a C compiler! gcp -L llvm/bin/clang stage1/bin/ @@ -51,11 +51,22 @@ if [[ -L llvm-host ]]; then gcp $GMP/lib/libgmp.a stage1/lib/ gcp $LIBUV/lib/libuv.a stage1/lib/ gcp $OPENSSL/lib/libssl.a $OPENSSL/lib/libcrypto.a stage1/lib/ - echo -n " -DLEAN_EXTRA_LINKER_FLAGS='-lgmp -luv -lssl -lcrypto'" + # macOS reads its trust store from the Keychain via the Security framework (and its + # CoreFoundation dependency). The standalone toolchain links with `--sysroot ROOT`, which does + # not search the host SDK, so bundle the framework stubs here just like libSystem above. We also + # bundle `libobjc.A.tbd` (under `usr/lib`, where the re-export's install name resolves) because + # CoreFoundation re-exports it. + for fw in CoreFoundation Security; do + mkdir -p stage1/lib/frameworks/$fw.framework + gcp -L $SDK/System/Library/Frameworks/$fw.framework/$fw.tbd stage1/lib/frameworks/$fw.framework/ + done + mkdir -p stage1/usr/lib + gcp -L $SDK/usr/lib/libobjc.A.tbd stage1/usr/lib/ + echo -n " -DLEAN_EXTRA_LINKER_FLAGS='-lgmp -luv -lssl -lcrypto -framework CoreFoundation -framework Security'" else echo -n " -DCMAKE_C_COMPILER=$PWD/llvm-host/bin/clang -DLEANC_OPTS='--sysroot $PWD/stage1 -resource-dir $PWD/stage1/lib/clang/15.0.1 ${EXTRA_FLAGS:-}'" fi echo -n " -DLEANC_INTERNAL_FLAGS='--sysroot ROOT -nostdinc -isystem ROOT/include/clang' -DLEANC_CC=ROOT/bin/clang" -echo -n " -DLEANC_INTERNAL_LINKER_FLAGS='--sysroot ROOT -L ROOT/lib -L ROOT/lib/libc -fuse-ld=lld'" +echo -n " -DLEANC_INTERNAL_LINKER_FLAGS='--sysroot ROOT -L ROOT/lib -L ROOT/lib/libc -F ROOT/lib/frameworks -fuse-ld=lld'" # do not set `LEAN_CC` for tests echo -n " -DLEAN_TEST_VARS=''" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 1c642adcd07b..d955e3fe1dda 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -426,6 +426,17 @@ if(NOT "${CMAKE_SYSTEM_NAME}" MATCHES "Emscripten") string(JOIN " " OPENSSL_LIBRARIES_STR ${OPENSSL_LIBRARIES}) if(NOT LEAN_STANDALONE) string(APPEND LEAN_EXTRA_LINKER_FLAGS " ${OPENSSL_LIBRARIES_STR}") + # macOS reads its trust store from the Keychain via the Security framework rather than from + # OpenSSL's default certificate paths, so link it (and its CoreFoundation dependency). + if(CMAKE_SYSTEM_NAME MATCHES "Darwin") + string(APPEND LEAN_EXTRA_LINKER_FLAGS " -framework CoreFoundation -framework Security") + endif() + + # OpenSSL's Windows trust store loader reaches the `ROOT` store through the CryptoAPI in + # crypt32, which a static libcrypto expects its consumer to provide. + if(CMAKE_SYSTEM_NAME MATCHES "Windows") + string(APPEND LEAN_EXTRA_LINKER_FLAGS " -lcrypt32") + endif() endif() endif() diff --git a/src/Std/Internal.lean b/src/Std/Internal.lean index 6b9838d97de7..24a16ec42e9a 100644 --- a/src/Std/Internal.lean +++ b/src/Std/Internal.lean @@ -11,6 +11,7 @@ public import Std.Http public import Std.Internal.ForIn public import Std.Internal.Parsec public import Std.Internal.UV +public import Std.Internal.SSL @[expose] public section diff --git a/src/Std/Internal/SSL.lean b/src/Std/Internal/SSL.lean new file mode 100644 index 000000000000..d7a66cec1273 --- /dev/null +++ b/src/Std/Internal/SSL.lean @@ -0,0 +1,8 @@ +/- +Copyright (c) 2026 Lean FRO, LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Authors: Sofia Rodrigues +-/ +module +prelude +public import Std.Internal.SSL.Context diff --git a/src/Std/Internal/SSL/Context.lean b/src/Std/Internal/SSL/Context.lean new file mode 100644 index 000000000000..90dffaa534fc --- /dev/null +++ b/src/Std/Internal/SSL/Context.lean @@ -0,0 +1,198 @@ +/- +Copyright (c) 2026 Lean FRO, LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Authors: Sofia Rodrigues +-/ +module +prelude +public import Init.System.IO + +/-! +OpenSSL context types for server and client TLS sessions. Contexts configure the TLS method, +certificate/key, peer-verification mode, and protocol options shared across all sessions created +from the same context. + +For every context, session tickets and TLS compression are disabled, renegotiation is refused, and +TLS 1.2 is the minimum version. A server built here therefore offers no session resumption; a client +does not resume either, since resuming additionally requires selecting a session per connection, +which the session layer never does. + +A context settles who is trusted, not who is being talked to: nothing here checks that a peer +certificate matches the host it came from. That check belongs to the session layer, which binds a +hostname per connection. + +The certificate, key and CA material passed to these constructors is refused outright when it is +encrypted, rather than prompted for, so no constructor can block on a terminal asking for a +passphrase. Material reached through `SSL_CERT_FILE` or `SSL_CERT_DIR` is read by OpenSSL with an +empty passphrase instead: it cannot prompt either, but an encrypted block whose passphrase happens +to be empty is decrypted and trusted there, where the same bytes in a `PEM.file` would be rejected. +-/ + +public section + +namespace Std.Internal.SSL + +/-- +PEM-encoded material, named either by the path of a file holding it or by its bytes directly. + +The two differ in how a NUL byte is treated. A path is passed to the OS as a C string, so an +embedded NUL is rejected outright; `PEM.text` is read with an explicit length, so a NUL is ordinary +input the PEM parser then has to make sense of. +-/ +inductive PEM where + + /-- + Read the PEM from the file at `path`. + -/ + | file (path : String) + + /-- + Take `contents` as the PEM bytes themselves. + -/ + | text (contents : String) + +namespace PEM + +@[inline] private def bytes : PEM → String + | .file path => path + | .text contents => contents + +@[inline] private def isFile : PEM → Bool + | .file _ => true + | .text _ => false + +end PEM + +private opaque ContextServerImpl : NonemptyType.{0} + +/-- +Server-side TLS context (`SSL_CTX` configured with `TLS_server_method`). +-/ +def Context.Server : Type := ContextServerImpl.type + +instance : Nonempty Context.Server := ContextServerImpl.property + +private opaque ContextClientImpl : NonemptyType.{0} + +/-- +Client-side TLS context (`SSL_CTX` configured with `TLS_client_method`). +-/ +def Context.Client : Type := ContextClientImpl.type + +instance : Nonempty Context.Client := ContextClientImpl.property + +namespace Context.Server + +/-- +The credentials a server presents. Both fields are required: a server that cannot prove who it is +has nothing to offer a client. +-/ +structure Config where + /-- + The leaf certificate followed by any intermediates. The whole chain is sent, so clients can build + a path to a trusted root. + -/ + cert : PEM + /-- An unencrypted private key matching the leaf in `cert`. -/ + key : PEM + +@[extern "lean_ssl_ctx_mk_server"] +private opaque mkImpl (cert : @& String) (certIsFile : Bool) (key : @& String) (keyIsFile : Bool) : + IO Context.Server + +/-- +Creates a server-side TLS context from the given certificate chain and private key. The server +presents its certificate but does not authenticate the client (no mutual TLS). + +The certificate is parsed but not validated against the clock: an expired certificate loads here and +is rejected by the peer at handshake time. A key that does not match the leaf certificate is +rejected, as is an encrypted key — decrypting one would mean asking for a passphrase. +-/ +def mk (cfg : Config) : IO Context.Server := + mkImpl cfg.cert.bytes cfg.cert.isFile cfg.key.bytes cfg.key.isFile + +end Server + +namespace Client + +/-- +Which anchors a client trusts, and whether it checks the peer against them at all. +-/ +structure Config where + /-- + Trust anchors supplied by the caller, trusted in addition to the platform anchors or — with + `trustSystemRoots := false` — instead of them. `none` supplies no anchors of its own. + + Private key and CRL entries in the material are ignored, so a bundle may hold them; no revocation + checking is performed. Material yielding no certificate at all is rejected. + -/ + ca : Option PEM := none + /-- + Whether to verify that the peer certificate chains to a trusted anchor. `false` disables + verification entirely, and neither `ca` nor the platform anchors are then consulted. This cannot + be undone: a context built this way can never be made to verify. + -/ + verifyPeer : Bool := true + /-- + Whether the platform default trust anchors are trusted. + + With `true`, connections to public HTTPS servers work out of the box. Which anchors those are is + platform-specific: the Keychain on macOS, the `ROOT` store on Windows, OpenSSL's configured paths + elsewhere. `SSL_CERT_FILE` and `SSL_CERT_DIR` are honoured on every platform, and are consulted + afresh for every context. On macOS the Keychain is read once per process, since doing so costs + around a tenth of a second, so a root added to it after the first context is built is not picked + up until the process restarts. The per-certificate trust settings decide, so a root added locally + (as `mkcert` and `security add-trusted-cert` do) is trusted and one explicitly denied is not; a + setting that applies only to a named host, key usage, or application grants no trust, since an + anchor cannot carry that restriction. OpenSSL's own bundle is not merged on top of the Keychain, + as it would reinstate the roots those settings turned away; it is read only when the Keychain + yields no anchor at all. `SSL_CERT_FILE` and `SSL_CERT_DIR` name locations of their own, which are + read in addition to the Keychain and do not drag OpenSSL's bundle in with them. + + With `false` none of that is consulted, environment variables included, and only `ca` is trusted. + -/ + trustSystemRoots : Bool := true + /-- + Whether a certificate in the trust store may anchor a chain without being self-signed itself. + + With `false`, the default, a chain is accepted only once it reaches a self-signed certificate, so + an intermediate CA cannot serve as a trust anchor. Supplying nothing but intermediates as `ca` + while also excluding the platform anchors then describes a context that could never verify + anything, and is rejected outright rather than left to fail at every handshake. Alongside the + platform anchors an intermediate is merely redundant, so it passes. + + With `true` any certificate in the store anchors a chain, which is what pinning to an intermediate + rather than to the root above it requires. + -/ + allowPartialChain : Bool := false + +@[extern "lean_ssl_ctx_mk_client"] +private opaque mkImpl (ca : @& String) (caIsFile : Bool) (hasCA : Bool) (verifyPeer : Bool) + (trustSystemRoots : Bool) (allowPartialChain : Bool) : IO Context.Client + +/-- +Creates a client-side TLS context trusting the anchors named by `cfg`. + +Pinning against a specific CA is `{ ca := some ca, trustSystemRoots := false }`: a certificate +issued by any other authority, public roots included, is then rejected. `ca` must supply at least +one certificate in that case, since a verifying context with no anchor at all could never complete a +handshake; that combination is refused here rather than at connection time. + +A trusted CA has to be self-signed unless `allowPartialChain` says otherwise, since a chain is only +accepted once it reaches a self-signed certificate. Pinning to nothing but intermediates is refused +here rather than failing at every handshake. + +Verifying the peer proves the certificate chains to a trusted anchor; it does **not** prove the +certificate belongs to the host being connected to. Binding a hostname is the session layer's job. +-/ +def mk (cfg : Config := {}) : IO Context.Client := + match cfg.ca with + | none => mkImpl "" false false cfg.verifyPeer cfg.trustSystemRoots cfg.allowPartialChain + | some ca => + mkImpl ca.bytes ca.isFile true cfg.verifyPeer cfg.trustSystemRoots cfg.allowPartialChain + +end Client +end Context +end Std.Internal.SSL + +end diff --git a/src/runtime/CMakeLists.txt b/src/runtime/CMakeLists.txt index 9950651a5b23..e7730a63d704 100644 --- a/src/runtime/CMakeLists.txt +++ b/src/runtime/CMakeLists.txt @@ -84,6 +84,8 @@ set( uv/system.cpp uv/signal.cpp openssl.cpp + openssl/context.cpp + openssl/trust_store.cpp ) add_library(leanrt_initial-exec STATIC ${RUNTIME_OBJS}) diff --git a/src/runtime/init_module.cpp b/src/runtime/init_module.cpp index 0c778bcd7fe2..9e5ce7c616cf 100644 --- a/src/runtime/init_module.cpp +++ b/src/runtime/init_module.cpp @@ -14,6 +14,8 @@ Author: Leonardo de Moura #include "runtime/mutex.h" #include "runtime/init_module.h" #include "runtime/libuv.h" +#include "runtime/openssl.h" +#include "runtime/openssl/context.h" namespace lean { // idempotent as it may be called both by the generated `main` and, via `lean_initialize`, @@ -31,6 +33,8 @@ extern "C" LEAN_EXPORT void lean_initialize_runtime_module() { initialize_mutex(); initialize_process(); initialize_stack_overflow(); + initialize_openssl(); + initialize_openssl_context(); initialize_libuv(); } void initialize_runtime_module() { @@ -38,6 +42,7 @@ void initialize_runtime_module() { } void finalize_runtime_module() { finalize_stack_overflow(); + finalize_openssl(); finalize_process(); finalize_mutex(); finalize_thread(); diff --git a/src/runtime/openssl.cpp b/src/runtime/openssl.cpp index 105678cf2b20..b28e697bfbbd 100644 --- a/src/runtime/openssl.cpp +++ b/src/runtime/openssl.cpp @@ -7,6 +7,7 @@ Author: Sofia Rodrigues #ifndef LEAN_EMSCRIPTEN #include +#include #include #include @@ -17,10 +18,25 @@ void initialize_openssl() { void finalize_openssl() {} +bool ensure_openssl_initialized() { + // `OPENSSL_INIT_NO_ATEXIT` is the load-bearing flag. By default OpenSSL registers + // `atexit(OPENSSL_cleanup)`, which tears down global state — among it the ENGINE lock that + // `SSL_CTX_new` reads — while other threads may still be inside OpenSSL, dereferencing the + // freed lock. Lean hands work to a thread pool that can outlive `main`, so that handler + // must not be installed. Nothing then frees OpenSSL's globals, which is intended: they stay + // reachable from static storage for the life of the process. + static const bool ok = OPENSSL_init_ssl(OPENSSL_INIT_NO_ATEXIT, nullptr) == 1; + + return ok; +} + } extern "C" LEAN_EXPORT lean_obj_res lean_openssl_version(lean_obj_arg o) { - return lean_unsigned_to_nat(OPENSSL_VERSION_NUMBER); + // The linked library rather than the headers it was compiled against, so a Lean binary running + // against an upgraded shared OpenSSL reports what it actually loaded (as `lean_libuv_version` + // does for libuv). + return lean_unsigned_to_nat(OpenSSL_version_num()); } #else diff --git a/src/runtime/openssl.h b/src/runtime/openssl.h index b7d091941f35..f4d2ee1dd2d3 100644 --- a/src/runtime/openssl.h +++ b/src/runtime/openssl.h @@ -6,4 +6,16 @@ Author: Sofia Rodrigues #pragma once #include -extern "C" LEAN_EXPORT lean_obj_res lean_openssl_version(lean_obj_arg); \ No newline at end of file +namespace lean { +void initialize_openssl(); +void finalize_openssl(); + +#ifndef LEAN_EMSCRIPTEN +// Initializes OpenSSL on first call, returning whether the library is usable. Deliberately lazy: a +// program that never opens a TLS connection never loads OpenSSL's providers. Every entry point that +// touches OpenSSL must call this first. +bool ensure_openssl_initialized(); +#endif +} + +extern "C" LEAN_EXPORT lean_obj_res lean_openssl_version(lean_obj_arg); diff --git a/src/runtime/openssl/context.cpp b/src/runtime/openssl/context.cpp new file mode 100644 index 000000000000..ff2d31ffc549 --- /dev/null +++ b/src/runtime/openssl/context.cpp @@ -0,0 +1,487 @@ +/* +Copyright (c) 2026 Lean FRO, LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: Sofia Rodrigues +*/ + +#include "runtime/openssl/context.h" +#include "runtime/openssl/trust_store.h" + +#ifndef LEAN_EMSCRIPTEN + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#endif + +namespace lean { + +lean_external_class * g_ssl_context_external_class = nullptr; + +#ifndef LEAN_EMSCRIPTEN + +static lean_obj_res reject_embedded_nul(b_obj_arg path) { + return strlen(lean_string_cstr(path)) == lean_string_size(path) - 1 + ? nullptr + : mk_embedded_nul_error(path); +} + +// PEM material the caller named: a path when `is_file`, otherwise the bytes themselves. +struct pem_source { + b_obj_arg obj; + bool is_file; + + char const * data() const { return lean_string_cstr(obj); } + size_t size() const { return lean_string_size(obj) - 1; } +}; + +// Reports a failure against a path. `errnum` is the `errno` the open failed with, or 0 for a +// failure with no OS error behind it (unparsable PEM, a key that does not match its certificate). +static lean_obj_res mk_ssl_file_error(b_obj_arg file, char const * msg, int errnum = 0) { + ERR_clear_error(); + + struct stat st; + + if (stat(lean_string_cstr(file), &st) == 0 && !S_ISREG(st.st_mode)) { + lean_inc(file); + return lean_io_result_mk_error(lean_mk_io_error_invalid_argument_file( + file, EINVAL, mk_string(std::string(msg) + " (the path is not a regular file)"))); + } + + if (errnum != 0) return lean_io_result_mk_error(decode_io_error(errnum, file)); + + lean_inc(file); + return lean_io_result_mk_error(lean_mk_io_error_invalid_argument_file( + file, EINVAL, mk_string(msg))); +} + +static int reject_encrypted_pem(char *, int, int, void *) { return -1; } + +// Reports a failure with no errno behind it, discarding the queue so it cannot taint a later one. +static lean_obj_res mk_ssl_invalid_argument(char const * msg) { + ERR_clear_error(); + return lean_io_result_mk_error(lean_mk_io_error_invalid_argument(EINVAL, mk_string(msg))); +} + +// Reports a failure against PEM material, naming the path when there is one to name. +static lean_obj_res mk_pem_error(pem_source src, char const * msg, int errnum = 0) { + return src.is_file ? mk_ssl_file_error(src.obj, msg, errnum) : mk_ssl_invalid_argument(msg); +} + +// Opens `src` for reading. On failure returns nullptr and stores an IO error in `*err`. +static BIO * open_pem_bio(pem_source src, char const * unreadable, lean_obj_res * err) { + if (src.is_file) { + // Captured here rather than recovered later by re-opening the path, which would both race + // and lose the distinction between an open failure and an unreadable file. + errno = 0; + BIO * bio = BIO_new_file(src.data(), "r"); + if (bio == nullptr) *err = mk_ssl_file_error(src.obj, unreadable, errno); + return bio; + } + + if (src.size() > (size_t)INT_MAX) { + *err = mk_ssl_invalid_argument("the PEM string is too large"); + return nullptr; + } + + BIO * bio = BIO_new_mem_buf(src.data(), (int)src.size()); + if (bio == nullptr) *err = mk_ssl_invalid_argument(unreadable); + return bio; +} + +// Whether a certificate was turned away on policy grounds rather than being unreadable as PEM. +static bool rejected_by_security_level() { + unsigned long err = ERR_peek_last_error(); + + if (ERR_GET_LIB(err) != ERR_LIB_SSL) return false; + + int reason = ERR_GET_REASON(err); + return reason == SSL_R_EE_KEY_TOO_SMALL || reason == SSL_R_CA_KEY_TOO_SMALL || + reason == SSL_R_CA_MD_TOO_WEAK; +} + +lean_object * mk_openssl_error(char const * where) { + std::string msg(where); + + for (int i = 0; i < 10; i++) { + unsigned long err = ERR_get_error(); + if (err == 0) break; + + char err_buf[256]; + ERR_error_string_n(err, err_buf, sizeof(err_buf)); + + msg += i == 0 ? ": " : "; "; + msg += err_buf; + } + + if (ERR_peek_error() != 0) { + msg += "; ... (truncated)"; + ERR_clear_error(); + } + + return lean_mk_io_user_error(mk_string(msg)); +} + +struct ssl_ctx_deleter { void operator()(SSL_CTX * ctx) const { SSL_CTX_free(ctx); } }; + +// Owns a context while it is still being built, so no error path has to remember to free it. +using ssl_ctx_ptr = std::unique_ptr; + +void initialize_openssl_context() { + g_ssl_context_external_class = lean_register_external_class( + [](void * ptr) { SSL_CTX_free((SSL_CTX*)ptr); }, [](void *, lean_object *) {}); +} + +// Applies the hardened options every context shares. The minimum protocol version is left to the +// caller, since it is the only one whose failure is worth reporting. +static void configure_ctx_options(SSL_CTX * ctx) { + SSL_CTX_set_options(ctx, + // No effect on TLS 1.3, which replaced renegotiation with key updates. + SSL_OP_NO_RENEGOTIATION | + + // Disables RFC 5077 session tickets in TLS 1.2. In TLS 1.3 it only downgrades them to the + // stateful form; the call below is what stops those being sent. + SSL_OP_NO_TICKET + ); + + // Without this a TLS 1.3 server still puts two NewSessionTickets on the wire per connection. + // Read only by the server state machine. + SSL_CTX_set_num_tickets(ctx, 0); + + // A backstop. Every read this file performs goes through a bare BIO and passes the callback + // itself, so nothing here consults this one; it is set so that any OpenSSL path reaching for the + // context's callback still cannot end up prompting on a terminal. + SSL_CTX_set_default_passwd_cb(ctx, reject_encrypted_pem); + + // Backs the flags above: a TLS 1.2 server still offers session-ID resumption through this cache, + // which defaults to SSL_SESS_CACHE_SERVER. The client half of the mask is already off there. + SSL_CTX_set_session_cache_mode(ctx, SSL_SESS_CACHE_OFF); + + // Lets a session layer relocate a buffered write between SSL_write() retries, as long as its + // contents stay identical, without tripping OpenSSL's buffer-stability check. + SSL_CTX_set_mode(ctx, SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER); + + // Inherited by every session, and inert until the session layer binds a peer hostname with + // SSL_set1_host; that check then rejects partial wildcards like `f*.example.com` (RFC 9525 + // §6.3, which obsoletes RFC 6125 — the latter still permitted them). + X509_VERIFY_PARAM_set_hostflags(SSL_CTX_get0_param(ctx), X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS); +} + +// Creates a configured SSL_CTX, or returns nullptr with an IO error stored in `*err`. +static ssl_ctx_ptr mk_ssl_ctx_base(const SSL_METHOD * method, lean_obj_res * err) { + ERR_clear_error(); + + ssl_ctx_ptr ctx(SSL_CTX_new(method)); + + if (ctx == nullptr) { + *err = mk_openssl_io_error("SSL_CTX_new failed"); + return nullptr; + } + + configure_ctx_options(ctx.get()); + + if (SSL_CTX_set_min_proto_version(ctx.get(), TLS1_2_VERSION) != 1) { + *err = mk_openssl_io_error("SSL_CTX_set_min_proto_version failed"); + return nullptr; + } + + return ctx; +} + +// Wraps a fully configured SSL_CTX into a Lean external object, taking ownership of it. +static lean_obj_res wrap_ssl_context(ssl_ctx_ptr ctx) { + lean_object * obj = lean_ssl_context_new(ctx.release()); + lean_mark_mt(obj); + + return lean_io_result_mk_ok(obj); +} + +// What `SSL_CTX_use_certificate_chain_file` does, against an arbitrary BIO: the leaf certificate +// plus every intermediate behind it, so the whole chain reaches the peer. There is no public +// `SSL_CTX_use_certificate_chain_bio`, so in-memory material has to go the long way round. +static bool use_certificate_chain_bio(SSL_CTX * ctx, BIO * bio) { + // `_AUX` so a certificate carrying OpenSSL's trust extensions is read the same way the file + // variant reads it. + X509 * leaf = PEM_read_bio_X509_AUX(bio, nullptr, reject_encrypted_pem, nullptr); + if (leaf == nullptr) return false; + + bool used = SSL_CTX_use_certificate(ctx, leaf) == 1; + X509_free(leaf); + + if (!used || SSL_CTX_clear_chain_certs(ctx) != 1) return false; + + while (X509 * ca = PEM_read_bio_X509(bio, nullptr, reject_encrypted_pem, nullptr)) { + // Takes ownership only on success. + if (SSL_CTX_add0_chain_cert(ctx, ca) != 1) { + X509_free(ca); + return false; + } + } + + // The loop ends either on a malformed block or on running out of them; only the latter is fine, + // so a corrupt intermediate is rejected rather than silently dropping the rest of the chain. + unsigned long err = ERR_peek_last_error(); + + if (ERR_GET_LIB(err) != ERR_LIB_PEM || ERR_GET_REASON(err) != PEM_R_NO_START_LINE) return false; + + ERR_clear_error(); + return true; +} + +// Loads the certificate chain the server presents and the key it signs with. +static lean_obj_res load_server_credentials(SSL_CTX * ctx, pem_source cert, pem_source key) { + ERR_clear_error(); + + char const * unreadable_cert = "could not read a PEM certificate chain"; + lean_obj_res err = nullptr; + BIO * cert_bio = open_pem_bio(cert, unreadable_cert, &err); + + if (cert_bio == nullptr) return err; + + bool cert_ok = use_certificate_chain_bio(ctx, cert_bio); + BIO_free(cert_bio); + + if (!cert_ok) { + return mk_pem_error(cert, rejected_by_security_level() + ? "the certificate is rejected by the TLS security level (key too small or signature " + "digest too weak)" + : unreadable_cert); + } + + ERR_clear_error(); + + char const * unreadable_key = "could not read an unencrypted PEM private key"; + char const * mismatch = "the private key does not match the certificate"; + BIO * key_bio = open_pem_bio(key, unreadable_key, &err); + + if (key_bio == nullptr) return err; + + EVP_PKEY * pkey = PEM_read_bio_PrivateKey(key_bio, nullptr, reject_encrypted_pem, nullptr); + BIO_free(key_bio); + + if (pkey == nullptr) return mk_pem_error(key, unreadable_key); + + bool used = SSL_CTX_use_PrivateKey(ctx, pkey) == 1; + EVP_PKEY_free(pkey); + + // A key of the certificate's own algorithm is compared here and rejected outright; one of a + // different algorithm lands in an unused slot instead, which only the check below catches. + if (!used) { + return mk_pem_error(key, ERR_GET_LIB(ERR_peek_last_error()) == ERR_LIB_X509 + ? mismatch + : unreadable_key); + } + + ERR_clear_error(); + + if (SSL_CTX_check_private_key(ctx) != 1) return mk_pem_error(key, mismatch); + + return nullptr; +} + +static lean_obj_res mk_server_ctx(b_obj_arg cert, uint8_t cert_is_file, b_obj_arg key, + uint8_t key_is_file) { + pem_source cert_src{cert, cert_is_file != 0}; + pem_source key_src{key, key_is_file != 0}; + + // Only a path has to survive the trip through a C string; in-memory PEM is read with a length, + // so a NUL there is data. + if (cert_src.is_file) { + if (lean_obj_res err = reject_embedded_nul(cert)) return err; + } + + if (key_src.is_file) { + if (lean_obj_res err = reject_embedded_nul(key)) return err; + } + + lean_obj_res base_err = nullptr; + ssl_ctx_ptr ctx = mk_ssl_ctx_base(TLS_server_method(), &base_err); + if (ctx == nullptr) return base_err; + + // The server presents its certificate but never authenticates the client (no mutual TLS). + SSL_CTX_set_verify(ctx.get(), SSL_VERIFY_NONE, nullptr); + + if (lean_obj_res err = load_server_credentials(ctx.get(), cert_src, key_src)) return err; + + return wrap_ssl_context(std::move(ctx)); +} + +// Adds every certificate `src` yields to the trust store, on top of whatever it already holds. +// With `require_self_signed`, the material must also hold a certificate a chain can terminate at. +static lean_obj_res load_ca_bundle(SSL_CTX * ctx, pem_source src, bool require_self_signed) { + ERR_clear_error(); + + char const * unreadable = "could not read PEM CA certificates"; + char const * no_certs = "the CA material contains no certificates"; + + lean_obj_res err = nullptr; + BIO * bio = open_pem_bio(src, unreadable, &err); + + if (bio == nullptr) return err; + + STACK_OF(X509_INFO) * infos = PEM_X509_INFO_read_bio(bio, nullptr, reject_encrypted_pem, nullptr); + BIO_free(bio); + + if (infos == nullptr) return mk_pem_error(src, unreadable); + + X509_STORE * store = SSL_CTX_get_cert_store(ctx); + int cert_count = 0; + bool any_self_signed = false; + + for (int i = 0, n = sk_X509_INFO_num(infos); i < n; i++) { + // A bundle may hold private keys and CRLs; only certificates are anchors. + X509 * cert = sk_X509_INFO_value(infos, i)->x509; + + if (cert == nullptr) continue; + cert_count++; + + // `EXFLAG_SS` is the same notion of self-signed that chain building terminates on. A bundle + // pairing a root with the intermediates below it therefore passes on the strength of the root. + if ((X509_get_extension_flags(cert) & EXFLAG_SS) != 0) any_self_signed = true; + + if (X509_STORE_add_cert(store, cert) != 1) { + err = mk_openssl_io_error("X509_STORE_add_cert failed"); + break; + } + } + + sk_X509_INFO_pop_free(infos, X509_INFO_free); + + if (err != nullptr) return err; + if (cert_count == 0) return mk_pem_error(src, no_certs); + + if (require_self_signed && !any_self_signed) { + return mk_pem_error(src, + "the CA material holds no self-signed certificate, so no chain can terminate in it " + "(supply the root, or allow partial chains to anchor at an intermediate)"); + } + + return nullptr; +} + +// `has_ca` says whether the caller supplied CA material at all, which decides whether dropping the +// platform anchors would leave nothing behind. `load_ca_bundle` is what enforces that supplied +// material actually yields a certificate, so the two together guarantee a verifying context has an +// anchor. +static lean_obj_res mk_client_ctx(uint8_t verify_peer, uint8_t trust_system_roots, + uint8_t allow_partial_chain, bool has_ca, pem_source ca) { + if (verify_peer && !trust_system_roots && !has_ca) { + return mk_ssl_invalid_argument( + "no trust anchors: peer verification is on, the platform trust anchors are excluded, " + "and no CA certificate was given"); + } + + lean_obj_res err = nullptr; + ssl_ctx_ptr ctx = mk_ssl_ctx_base(TLS_client_method(), &err); + if (ctx == nullptr) return err; + + // With verification off the CA material is never consulted. + if (!verify_peer) { + SSL_CTX_set_verify(ctx.get(), SSL_VERIFY_NONE, nullptr); + return wrap_ssl_context(std::move(ctx)); + } + + if (allow_partial_chain) { + // Lets chain building stop at any certificate in the store rather than only at a self-signed + // one, which is what anchoring on an intermediate requires. + X509_VERIFY_PARAM_set_flags(SSL_CTX_get0_param(ctx.get()), X509_V_FLAG_PARTIAL_CHAIN); + } + + if (trust_system_roots) { + std::string detail; + + if (!load_system_trust_store(ctx.get(), &detail)) { + std::string msg("failed to load system trust store"); + if (!detail.empty()) msg += ": " + detail; + + return lean_io_result_mk_error(mk_openssl_error(msg.c_str())); + } + } + + // The caller's own CAs are added to whatever the store already holds: on top of the platform + // anchors, or into an otherwise empty store when those were excluded. + if (has_ca) { + // An anchor that cannot terminate a chain is only a dead configuration when it is the sole + // source of anchors; alongside the platform roots it is merely redundant. + bool require_self_signed = !allow_partial_chain && !trust_system_roots; + + if (lean_obj_res ca_err = load_ca_bundle(ctx.get(), ca, require_self_signed)) return ca_err; + } + + SSL_CTX_set_verify(ctx.get(), SSL_VERIFY_PEER, nullptr); + return wrap_ssl_context(std::move(ctx)); +} + +static lean_obj_res mk_client_ctx_checked(b_obj_arg ca, uint8_t ca_is_file, uint8_t has_ca, + uint8_t verify_peer, uint8_t trust_system_roots, + uint8_t allow_partial_chain) { + pem_source ca_src{ca, ca_is_file != 0}; + + // Checked before `verifyPeer` is consulted, so a path that could never be opened is reported as + // such even where it would not have been read. + if (has_ca && ca_src.is_file) { + if (lean_obj_res err = reject_embedded_nul(ca)) return err; + } + + return mk_client_ctx(verify_peer, trust_system_roots, allow_partial_chain, has_ca != 0, ca_src); +} + +// Runs a constructor behind the two guards every entry point needs: OpenSSL initialized before any +// `ERR_*` call can register `atexit(OPENSSL_cleanup)` behind `OPENSSL_INIT_NO_ATEXIT`'s back, and no +// C++ exception escaping into Lean-generated code. +template +static lean_obj_res ssl_entry_point(F && build) { + try { + if (!ensure_openssl_initialized()) { + return lean_io_result_mk_error(lean_mk_io_user_error( + mk_string("OPENSSL_init_ssl failed"))); + } + + return build(); + } catch (std::exception & ex) { + return lean_io_result_mk_error(lean_mk_io_user_error(mk_string(ex.what()))); + } +} + +/* Std.Internal.SSL.Context.Server.mkImpl (cert : @& String) (certIsFile : Bool) + (key : @& String) (keyIsFile : Bool) : IO Context.Server */ +extern "C" LEAN_EXPORT lean_obj_res lean_ssl_ctx_mk_server(b_obj_arg cert, uint8_t cert_is_file, b_obj_arg key, uint8_t key_is_file) { + return ssl_entry_point([&] { return mk_server_ctx(cert, cert_is_file, key, key_is_file); }); +} + +/* Std.Internal.SSL.Context.Client.mkImpl (ca : @& String) (caIsFile hasCA verifyPeer + trustSystemRoots allowPartialChain : Bool) : IO Context.Client */ +extern "C" LEAN_EXPORT lean_obj_res lean_ssl_ctx_mk_client(b_obj_arg ca, uint8_t ca_is_file, uint8_t has_ca, uint8_t verify_peer, uint8_t trust_system_roots, uint8_t allow_partial_chain) { + return ssl_entry_point([&] { + return mk_client_ctx_checked(ca, ca_is_file, has_ca, verify_peer, trust_system_roots, allow_partial_chain); + }); +} + +#else + +void initialize_openssl_context() {} + +extern "C" LEAN_EXPORT lean_obj_res lean_ssl_ctx_mk_server(b_obj_arg /*cert*/, + uint8_t /*cert_is_file*/, b_obj_arg /*key*/, uint8_t /*key_is_file*/) { + lean_always_assert(false && "Please build a version of Lean4 with OpenSSL to invoke this."); +} + +extern "C" LEAN_EXPORT lean_obj_res lean_ssl_ctx_mk_client(b_obj_arg /*ca*/, uint8_t /*ca_is_file*/, + uint8_t /*has_ca*/, uint8_t /*verify_peer*/, uint8_t /*trust_system_roots*/, + uint8_t /*allow_partial_chain*/) { + lean_always_assert(false && "Please build a version of Lean4 with OpenSSL to invoke this."); +} + +#endif + +} diff --git a/src/runtime/openssl/context.h b/src/runtime/openssl/context.h new file mode 100644 index 000000000000..27c987fe2f2a --- /dev/null +++ b/src/runtime/openssl/context.h @@ -0,0 +1,39 @@ +/* +Copyright (c) 2026 Lean FRO, LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: Sofia Rodrigues +*/ +#pragma once + +#include +#include "runtime/io.h" +#include "runtime/object.h" +#include "runtime/openssl.h" + +#ifndef LEAN_EMSCRIPTEN +#include +#endif + +namespace lean { + +extern lean_external_class * g_ssl_context_external_class; +void initialize_openssl_context(); + +#ifndef LEAN_EMSCRIPTEN + +// Drains the OpenSSL error queue and returns a single error message combining up to 10 entries. +lean_object * mk_openssl_error(char const * where); +inline lean_obj_res mk_openssl_io_error(char const * where) { return lean_io_result_mk_error(mk_openssl_error(where)); } +inline lean_object * lean_ssl_context_new(SSL_CTX * ctx) { return lean_alloc_external(g_ssl_context_external_class, ctx); } +inline SSL_CTX * lean_to_ssl_context(lean_object * o) { return (SSL_CTX*)lean_get_external_data(o); } +#endif + +// ======================================= +// Context Operations + +extern "C" LEAN_EXPORT lean_obj_res lean_ssl_ctx_mk_server(b_obj_arg cert, uint8_t cert_is_file, + b_obj_arg key, uint8_t key_is_file); +extern "C" LEAN_EXPORT lean_obj_res lean_ssl_ctx_mk_client(b_obj_arg ca, uint8_t ca_is_file, + uint8_t has_ca, uint8_t verify_peer, uint8_t trust_system_roots, uint8_t allow_partial_chain); + +} diff --git a/src/runtime/openssl/trust_store.cpp b/src/runtime/openssl/trust_store.cpp new file mode 100644 index 000000000000..4369be4b6356 --- /dev/null +++ b/src/runtime/openssl/trust_store.cpp @@ -0,0 +1,334 @@ +/* +Copyright (c) 2026 Lean FRO, LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: Sofia Rodrigues +*/ + +#include "runtime/openssl/trust_store.h" + +#ifndef LEAN_EMSCRIPTEN + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__APPLE__) +#include +#include +#endif + +namespace lean { + +#if defined(__APPLE__) || defined(LEAN_WINDOWS) + +// A variable set to the empty string names no path, so it is reported as unset. OpenSSL's own check is +// a bare non-NULL test, which takes the empty string for a path, finds nothing, and quietly leaves the +// store without those anchors. +static char const * getenv_or_null_if_empty(char const * name) { + char const * value = getenv(name); + return value != nullptr && value[0] != '\0' ? value : nullptr; +} + +// Whether the store demonstrably holds no trust anchor. +static bool trust_store_has_no_certs(X509_STORE * store) { + if (char const * dir = getenv_or_null_if_empty(X509_get_default_cert_dir_env())) { +#if defined(LEAN_WINDOWS) + char const sep = ';'; +#else + char const sep = ':'; +#endif + std::string list(dir); + + for (size_t p = 0; p <= list.size(); ) { + size_t end = std::min(list.find(sep, p), list.size()); + std::string entry = list.substr(p, end - p); + struct stat st; + + if (!entry.empty() && stat(entry.c_str(), &st) == 0 && S_ISDIR(st.st_mode)) return false; + p = end + 1; + } + } + + STACK_OF(X509) * certs = X509_STORE_get1_all_certs(store); + if (certs == nullptr) return true; + + bool empty = sk_X509_num(certs) == 0; + sk_X509_pop_free(certs, X509_free); + + return empty; +} +#endif + +#if defined(__APPLE__) + +// Every anchor the Keychain offers for TLS, or null if even the empty list could not be built. +static STACK_OF(X509) * g_keychain_anchors = nullptr; +static std::once_flag g_keychain_anchors_once; + +static bool cf_is(CFTypeRef value, CFTypeID type) { + return value != nullptr && CFGetTypeID(value) == type; +} + +static bool cf_array_contains(CFArrayRef array, CFTypeRef value) { + return array != nullptr && CFArrayContainsValue(array, CFRangeMake(0, CFArrayGetCount(array)), value); +} + +static bool as_number(CFTypeRef value, int64_t * out) { + return cf_is(value, CFNumberGetTypeID()) && CFNumberGetValue((CFNumberRef)value, kCFNumberSInt64Type, out); +} + +// Whether a trust setting's policy governs TLS at all (there are other types of things that the policy +// can deal with). +static bool policy_covers_tls(SecPolicyRef policy) { + CFDictionaryRef props = SecPolicyCopyProperties(policy); + if (props == nullptr) return false; + + CFTypeRef oid = CFDictionaryGetValue(props, kSecPolicyOid); + CFTypeRef client = CFDictionaryGetValue(props, kSecPolicyClient); + + bool tls = cf_is(oid, CFStringGetTypeID()) && CFEqual(oid, kSecPolicyAppleSSL) && + !(cf_is(client, CFBooleanGetTypeID()) && CFBooleanGetValue((CFBooleanRef)client)); + + CFRelease(props); + return tls; +} + +static bool rule_names_client_policy(CFDictionaryRef rule) { + CFTypeRef name = CFDictionaryGetValue(rule, CFSTR("kSecTrustSettingsPolicyName")); + return cf_is(name, CFStringGetTypeID()) && CFEqual(name, CFSTR("sslClient")); +} + +// Whether a rule carries a usage constraint beyond its policy that a trust store cannot record. +static bool constrained_beyond_policy(CFDictionaryRef rule) { + // A host name for the TLS policy, and the application performing the verification. + if (CFDictionaryGetValue(rule, kSecTrustSettingsPolicyString) != nullptr || + CFDictionaryGetValue(rule, kSecTrustSettingsApplication) != nullptr) { + return true; + } + + CFTypeRef usage = CFDictionaryGetValue(rule, kSecTrustSettingsKeyUsage); + if (usage == nullptr) return false; + + int64_t bits = 0; + return !as_number(usage, &bits) || (bits & kSecTrustSettingsKeyUseSignCert) == 0; +} + +enum class trust_setting { unspecified, trusted, denied }; + +// What one domain's trust settings say about using `cert` as a TLS anchor. +static trust_setting tls_trust_setting(SecCertificateRef cert, SecTrustSettingsDomain domain) { + CFArrayRef settings = nullptr; + + if (SecTrustSettingsCopyTrustSettings(cert, domain, &settings) != errSecSuccess) { + if (settings != nullptr) CFRelease(settings); + return trust_setting::unspecified; + } + + if (settings == nullptr) return trust_setting::unspecified; + + // An empty settings array is how Apple encodes unconditional trust. + CFIndex count = CFArrayGetCount(settings); + trust_setting result = count == 0 ? trust_setting::trusted : trust_setting::unspecified; + + for (CFIndex i = 0; i < count; i++) { + CFTypeRef entry = CFArrayGetValueAtIndex(settings, i); + if (!cf_is(entry, CFDictionaryGetTypeID())) continue; + + CFDictionaryRef rule = (CFDictionaryRef)entry; + + if (rule_names_client_policy(rule)) continue; + + CFTypeRef policy = CFDictionaryGetValue(rule, kSecTrustSettingsPolicy); + if (policy != nullptr && + !(cf_is(policy, SecPolicyGetTypeID()) && policy_covers_tls((SecPolicyRef)policy))) { + continue; + } + + CFTypeRef result_value = CFDictionaryGetValue(rule, kSecTrustSettingsResult); + int64_t verdict = kSecTrustSettingsResultTrustRoot; + if (result_value != nullptr && !as_number(result_value, &verdict)) continue; + + if (verdict == kSecTrustSettingsResultDeny) { + result = trust_setting::denied; + break; + } + + if ((verdict == kSecTrustSettingsResultTrustRoot || + verdict == kSecTrustSettingsResultTrustAsRoot) && !constrained_beyond_policy(rule)) { + result = trust_setting::trusted; + } + } + + CFRelease(settings); + return result; +} + +static const SecTrustSettingsDomain g_trust_domains[] = { + kSecTrustSettingsDomainUser, + kSecTrustSettingsDomainAdmin, + kSecTrustSettingsDomainSystem, +}; + +static constexpr size_t g_trust_domain_count = std::size(g_trust_domains); + +// The highest-ranked domain with an opinion about `cert` is the one that decides. +static bool trusted_as_tls_anchor(SecCertificateRef cert, CFArrayRef const * listed, + bool const * unknown, size_t found_in) { + for (size_t d = 0; d < g_trust_domain_count; d++) { + if (d != found_in && !unknown[d] && !cf_array_contains(listed[d], cert)) continue; + + switch (tls_trust_setting(cert, g_trust_domains[d])) { + case trust_setting::trusted: return true; + case trust_setting::denied: return false; + case trust_setting::unspecified: break; + } + } + + return false; +} + +static void collect_keychain_anchors() { + g_keychain_anchors = sk_X509_new_null(); + if (g_keychain_anchors == nullptr) return; + + CFArrayRef listed[g_trust_domain_count] = {}; + bool unknown[g_trust_domain_count] = {}; + + for (size_t d = 0; d < g_trust_domain_count; d++) { + OSStatus status = SecTrustSettingsCopyCertificates(g_trust_domains[d], &listed[d]); + + if (status != errSecSuccess) { + if (listed[d] != nullptr) CFRelease(listed[d]); + listed[d] = nullptr; + + // `errSecNoTrustSettings` is the domain reporting that it holds none, which is the + // ordinary state of the user and administrator domains. Any other failure leaves its + // contents unknown, and a domain that could not be listed still has to be asked about + // every certificate, or a verdict it holds — a deny above all — is skipped unseen. + unknown[d] = status != errSecNoTrustSettings; + } + } + + for (size_t d = 0; d < g_trust_domain_count; d++) { + if (listed[d] == nullptr) continue; + + for (CFIndex i = 0, n = CFArrayGetCount(listed[d]); i < n; i++) { + CFTypeRef entry = CFArrayGetValueAtIndex(listed[d], i); + if (!cf_is(entry, SecCertificateGetTypeID())) continue; + + SecCertificateRef cert = (SecCertificateRef)entry; + if (!trusted_as_tls_anchor(cert, listed, unknown, d)) continue; + + CFDataRef der = SecCertificateCopyData(cert); + if (der == nullptr) continue; + + const unsigned char * data = CFDataGetBytePtr(der); + X509 * x509 = d2i_X509(nullptr, &data, CFDataGetLength(der)); + CFRelease(der); + if (x509 == nullptr) continue; + + // These certificates are shared by every context, so they are read concurrently. OpenSSL + // fills a certificate's extension cache on its first use in a verification, under the + // certificate's own lock; filling it here instead keeps that write on this thread, and so + // does not rest on how the linked OpenSSL orders it. + X509_check_purpose(x509, -1, -1); + + if (sk_X509_push(g_keychain_anchors, x509) == 0) X509_free(x509); + } + } + + for (size_t d = 0; d < g_trust_domain_count; d++) { + if (listed[d] != nullptr) CFRelease(listed[d]); + } +} +#endif + +bool load_system_trust_store(SSL_CTX * ctx, std::string * detail) { +#if defined(__APPLE__) + std::call_once(g_keychain_anchors_once, collect_keychain_anchors); + + X509_STORE * store = SSL_CTX_get_cert_store(ctx); + int anchor_count = g_keychain_anchors != nullptr ? sk_X509_num(g_keychain_anchors) : 0; + bool any_anchor = false; + + // The store takes a reference to each anchor. A certificate listed by more than one domain is + // deduplicated and reported as success. + for (int i = 0; i < anchor_count; i++) { + if (X509_STORE_add_cert(store, sk_X509_value(g_keychain_anchors, i)) == 1) any_anchor = true; + } + + // The env-named locations are loaded on their own because `SSL_CTX_set_default_verify_paths` would + // also arm the compiled-in sibling of whichever variable is unset, and OpenSSL's bundle is read + // whole: merging it would put back every anchor the trust settings above turned away, and nothing + // can take an anchor out of the store again. It is left to the `!any_anchor` fallback below, where + // there is no verdict left to contradict. + char const * env_file = getenv_or_null_if_empty(X509_get_default_cert_file_env()); + char const * env_dir = getenv_or_null_if_empty(X509_get_default_cert_dir_env()); + + // Naming a bundle that cannot be read is a configuration error, and the only one of these loads + // whose failure can be diagnosed: a hash directory resolves lazily, and the default-paths call + // discards its result. Unreported it resurfaces much later, as a verification failure against + // the anchor that never loaded. + if (env_file != nullptr && X509_STORE_load_file(store, env_file) != 1) { + *detail = std::string(X509_get_default_cert_file_env()) + + " names a file holding no readable certificate"; + return false; + } + + if (env_dir != nullptr) X509_STORE_load_path(store, env_dir); + + if (!any_anchor) { + SSL_CTX_set_default_verify_paths(ctx); + + if (trust_store_has_no_certs(store)) { + *detail = "the Keychain yielded no anchor trusted for TLS and OpenSSL's default paths " + "hold no certificate either"; + return false; + } + } + + // Entries left by a certificate the loop skipped, or by a configured path that does not exist, + // would otherwise be picked up by a later, unrelated diagnosis as its own. + ERR_clear_error(); + return true; +#elif defined(LEAN_WINDOWS) + // The Windows ROOT store is reachable only through OpenSSL's winstore loader (added in OpenSSL + // 3.2), which `SSL_CTX_set_default_verify_paths` does not consult, so it has to be named explicitly. + int winstore = SSL_CTX_load_verify_store(ctx, "org.openssl.winstore://"); + + // The fallback for builds without that loader. + SSL_CTX_set_default_verify_paths(ctx); + + // A successful winstore load resolves lazily and is invisible to the count, so the count is + // consulted only once winstore is out of the picture. What can still rescue that case is an + // `SSL_CERT_FILE` bundle, which is read on the spot, or an `SSL_CERT_DIR` that exists. + if (winstore != 1 && trust_store_has_no_certs(SSL_CTX_get_cert_store(ctx))) { + *detail = "the Windows ROOT store is unavailable (it needs OpenSSL 3.2 or later) and no CA file was configured"; + return false; + } + + ERR_clear_error(); + return true; +#else + // Only registering the lookups can fail here; whether they resolve to any certificate is not + // reported, so an installation with no CA material at all passes and fails at handshake time. + if (SSL_CTX_set_default_verify_paths(ctx) != 1) { + *detail = "OpenSSL's default certificate paths could not be registered"; + return false; + } + + ERR_clear_error(); + return true; +#endif +} + +} + +#endif diff --git a/src/runtime/openssl/trust_store.h b/src/runtime/openssl/trust_store.h new file mode 100644 index 000000000000..0d6077adbcd7 --- /dev/null +++ b/src/runtime/openssl/trust_store.h @@ -0,0 +1,26 @@ +/* +Copyright (c) 2026 Lean FRO, LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: Sofia Rodrigues +*/ +#pragma once + +#include + +#ifndef LEAN_EMSCRIPTEN +#include +#include +#endif + +namespace lean { + +#ifndef LEAN_EMSCRIPTEN + +// Loads the platform's root certificates into `ctx`'s store so clients verify public servers out of +// the box, setting `*detail` to the platform-level cause of a failure the OpenSSL error queue does +// not carry. The anchors are added to whatever the store already holds, never in place of it. +bool load_system_trust_store(SSL_CTX * ctx, std::string * detail); + +#endif + +} diff --git a/tests/elab/async_ssl_certs/README.md b/tests/elab/async_ssl_certs/README.md new file mode 100644 index 000000000000..b0002b0c8a02 --- /dev/null +++ b/tests/elab/async_ssl_certs/README.md @@ -0,0 +1,90 @@ +# TLS test certificate fixtures + +Certificate fixtures used by the `async_ssl_*` tests, self-signed but for `intermediate.pem`. These contain **no secrets**: the +private key exists only so the tests can drive a real TLS handshake, and nothing outside the +test suite trusts these certificates. They are committed as fixtures (instead of generated at +test time) so the tests neither shell out to the `openssl` CLI nor depend on it being +installed — subprocess spawning in these tests also produced spurious LeakSanitizer reports +in the sanitizer CI build. + +All certificates are signed by `key.pem` (RSA-2048) and are valid until 2126, with two exceptions: +`expired.pem`, whose validity window is entirely in 2020 (building a context parses a certificate +without checking its validity period, so this one is rejected only at handshake time), and `weakcert.pem`, which is self-signed under a throwaway 512-bit key that is not +kept. + +| file | subject | notes | +|---|---|---| +| `key.pem` | | RSA-2048 private key for all certs below | +| `key2.pem` | | second RSA-2048 key, matching none of the certificates | +| `eckey.pem` | | P-256 key; a *different algorithm* from every certificate here, which OpenSSL accepts against an RSA certificate unless `SSL_CTX_check_private_key` is consulted | +| `enckey.pem` | | `key.pem` encrypted with the passphrase `lean4`; encrypted keys are unsupported and must be rejected without prompting for one | +| `emptypwkey.pem` | | `key.pem` encrypted under an *empty* passphrase; still an encrypted key, and rejected only because the password callback reports a failure rather than a zero-length passphrase | +| `tradkey.pem` | | `key.pem` in the traditional (RFC 1421) encoding rather than PKCS#8; the only key form that reaches the bundle loader as a parsed entry carrying no certificate, so it exercises the skip branch | +| `enccert.pem` | | `cert.pem` as an RFC 1421 encrypted `CERTIFICATE` block; decrypted in place while a bundle is read, so it is the input that makes a missing password callback prompt on the terminal and hang | +| `cert.pem` | `CN=localhost` | standard server cert (no SAN; hostname matching uses the CN fallback) | +| `wildcard.pem` | `CN=*.test.local` | SAN: `DNS:*.test.local, DNS:test.local` | +| `multisan.pem` | `CN=alpha.test.local` | SAN: `DNS:alpha.test.local, DNS:beta.test.local` | +| `expired.pem` | `CN=localhost` | valid 2020-01-01 → 2020-01-02 only | +| `corrupt.pem` | | `cert.pem` with one bit flipped in the first DER byte (`SEQUENCE` tag → `SET`) | +| `weakcert.pem` | `CN=localhost` | self-signed under a 512-bit RSA key; parses perfectly but is below every security level a build may default to, so it is refused on policy grounds rather than as unreadable PEM | +| `intermediate.pem` | `CN=Test Intermediate CA` | a CA signed by `cert.pem` rather than by itself, so no chain can terminate at it; the only fixture whose issuer differs from its subject | +| `crl.pem` | | a CRL issued by `cert.pem`; the non-certificate bundle entry that is *not* a private key, so it is what distinguishes "holds no certificates" from "could not be read" | + +`corrupt.pem` still has intact PEM armour and valid base64 — it differs from `cert.pem` by a single +character — so it only fails once the certificate body is decoded, which exercises the +"malformed certificate" paths rather than the "not a PEM file" ones. + +To regenerate (`-not_before`/`-not_after` need OpenSSL 3.5 or later; on older versions use +`-startdate`/`-enddate`): + +```sh +openssl genrsa -out key.pem 2048 +openssl req -new -x509 -key key.pem -out cert.pem -days 36500 -subj "/CN=localhost" +openssl req -new -x509 -key key.pem -out wildcard.pem -days 36500 -subj "/CN=*.test.local" \ + -addext "subjectAltName=DNS:*.test.local,DNS:test.local" +openssl req -new -x509 -key key.pem -out multisan.pem -days 36500 -subj "/CN=alpha.test.local" \ + -addext "subjectAltName=DNS:alpha.test.local,DNS:beta.test.local" +openssl req -new -key key.pem -out expired.csr -subj "/CN=localhost" +openssl x509 -req -in expired.csr -signkey key.pem -out expired.pem -set_serial 99 \ + -not_before 20200101000000Z -not_after 20200102000000Z && rm expired.csr +openssl genrsa -out key2.pem 2048 +openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out eckey.pem +openssl pkey -in key.pem -aes256 -passout pass:lean4 -out enckey.pem +openssl pkey -in key.pem -aes256 -passout pass: -out emptypwkey.pem +openssl rsa -in key.pem -traditional -out tradkey.pem +# 512-bit key kept only long enough to self-sign the certificate; nothing loads it. 1024 bits would +# sit exactly on security level 1's floor, making the test depend on which level the build defaults to. +openssl req -x509 -newkey rsa:512 -keyout weakkey.pem -out weakcert.pem -days 36500 -nodes \ + -subj "/CN=localhost" && rm weakkey.pem +openssl req -new -key key2.pem -out inter.csr -subj "/CN=Test Intermediate CA" +openssl x509 -req -in inter.csr -CA cert.pem -CAkey key.pem -set_serial 42 -days 36500 \ + -extfile <(printf 'basicConstraints=critical,CA:TRUE\nkeyUsage=critical,keyCertSign,cRLSign\n') \ + -out intermediate.pem && rm inter.csr +mkdir -p ca/newcerts && touch ca/index.txt && echo 01 > ca/crlnumber +printf '[ca]\ndefault_ca=CA_default\n[CA_default]\ndatabase=./ca/index.txt\ncrlnumber=./ca/crlnumber\ndefault_md=sha256\ndefault_crl_days=36500\n' > ca/openssl.cnf +openssl ca -config ca/openssl.cnf -gencrl -cert cert.pem -keyfile key.pem -out crl.pem && rm -r ca +python3 -c ' +import base64, hashlib, subprocess, textwrap +# Legacy RFC 1421 PEM encryption: key = EVP_BytesToKey(MD5, salt=IV, passphrase), 24 bytes for 3DES. +iv = bytes(range(1, 9)) +pw = b"lean4" +d = b""; key = b"" +while len(key) < 24: + d = hashlib.md5(d + pw + iv).digest() + key += d +key = key[:24] +der = base64.b64decode("".join(open("cert.pem").read().strip().splitlines()[1:-1])) +enc = subprocess.run(["openssl", "enc", "-des-ede3-cbc", "-K", key.hex(), "-iv", iv.hex()], + input=der, capture_output=True, check=True).stdout +body = "\n".join(textwrap.wrap(base64.b64encode(enc).decode(), 64)) +open("enccert.pem", "w").write("-----BEGIN CERTIFICATE-----\nProc-Type: 4,ENCRYPTED\n" + f"DEK-Info: DES-EDE3-CBC,{iv.hex().upper()}\n\n" + body + "\n-----END CERTIFICATE-----\n") +' +python3 -c ' +import base64, textwrap +der = bytearray(base64.b64decode("".join(open("cert.pem").read().strip().splitlines()[1:-1]))) +der[0] = 0x31 +body = "\n".join(textwrap.wrap(base64.b64encode(bytes(der)).decode(), 64)) +open("corrupt.pem", "w").write("-----BEGIN CERTIFICATE-----\n" + body + "\n-----END CERTIFICATE-----\n") +' +``` diff --git a/tests/elab/async_ssl_certs/cert.pem b/tests/elab/async_ssl_certs/cert.pem new file mode 100644 index 000000000000..414acf80d633 --- /dev/null +++ b/tests/elab/async_ssl_certs/cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDCzCCAfOgAwIBAgIUfBsMFFfMmVyfKr1HjIF9ZUsOz0MwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDcwNDE1NDQyNVoYDzIxMjYw +NjEwMTU0NDI1WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQC7SwGfQ+WLyJwnRlX37WMUEDT/YVZd0PV/6PPJSFx3 +0z2vZnqMh9S7gQPvkYkon7qMtqF5jlJt3zDmddjuhhwHqeNj1htKnWPjhh8rc2DG +7v9u/36O92fz2jKUn8qHGG80+SiW4LkE8uXuC/ia0a1W03iT7rApICuSIgNrP5Zr +XZ3pHxn4m7GxnOxm/5jt0SX3HQkRV+VMEo0cGEq/8ZvmwnOOG14C/o/FxFw9zxw8 +pDTabvfLVxoHCMOu7UB3c0Hg6SzM8cD/QefWQRLyD/rZIw34GcTs9IklWxJ0loqj +Y1q0c5p5991zRC2SqmM6vpAjc6dpijIAZvsycewlnY1bAgMBAAGjUzBRMB0GA1Ud +DgQWBBRam+qywW30FsQlhzW2SV7dHs96NDAfBgNVHSMEGDAWgBRam+qywW30FsQl +hzW2SV7dHs96NDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQC7 +ItNAWGWOQDfjSCi2XqbKPSMbo3d8x2fQclYuFXu3QjbsmTrkzCehvGAyXHUtbnwa +wAufdEDKjfmUZmquVQd54oTCDgNtDF4729kD7pBeIIyhWyH0osPAs9mva37ripqC +MQ3kMClzS8FSBhB03CSdkypzx0znI2rIcxbMDPCIoYtkKYyvc6/yztWZfVbhHWPC +6bYAHOFpqFCOSzcZFwzWjWmAnH+pPEX8khDTTY676VG/Yuy2F/BgCdXco8VE+kiW +hh/mZMXyGuGKuYexz8Tv5M3qzdqxNmhFObyJPk/Y7XgIoBtdyHLMkqYN5fnlUMtQ +gjuJv2wDBVKza1YhNdr1 +-----END CERTIFICATE----- diff --git a/tests/elab/async_ssl_certs/corrupt.pem b/tests/elab/async_ssl_certs/corrupt.pem new file mode 100644 index 000000000000..2b4ed0ec16b9 --- /dev/null +++ b/tests/elab/async_ssl_certs/corrupt.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MYIDCzCCAfOgAwIBAgIUfBsMFFfMmVyfKr1HjIF9ZUsOz0MwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDcwNDE1NDQyNVoYDzIxMjYw +NjEwMTU0NDI1WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQC7SwGfQ+WLyJwnRlX37WMUEDT/YVZd0PV/6PPJSFx3 +0z2vZnqMh9S7gQPvkYkon7qMtqF5jlJt3zDmddjuhhwHqeNj1htKnWPjhh8rc2DG +7v9u/36O92fz2jKUn8qHGG80+SiW4LkE8uXuC/ia0a1W03iT7rApICuSIgNrP5Zr +XZ3pHxn4m7GxnOxm/5jt0SX3HQkRV+VMEo0cGEq/8ZvmwnOOG14C/o/FxFw9zxw8 +pDTabvfLVxoHCMOu7UB3c0Hg6SzM8cD/QefWQRLyD/rZIw34GcTs9IklWxJ0loqj +Y1q0c5p5991zRC2SqmM6vpAjc6dpijIAZvsycewlnY1bAgMBAAGjUzBRMB0GA1Ud +DgQWBBRam+qywW30FsQlhzW2SV7dHs96NDAfBgNVHSMEGDAWgBRam+qywW30FsQl +hzW2SV7dHs96NDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQC7 +ItNAWGWOQDfjSCi2XqbKPSMbo3d8x2fQclYuFXu3QjbsmTrkzCehvGAyXHUtbnwa +wAufdEDKjfmUZmquVQd54oTCDgNtDF4729kD7pBeIIyhWyH0osPAs9mva37ripqC +MQ3kMClzS8FSBhB03CSdkypzx0znI2rIcxbMDPCIoYtkKYyvc6/yztWZfVbhHWPC +6bYAHOFpqFCOSzcZFwzWjWmAnH+pPEX8khDTTY676VG/Yuy2F/BgCdXco8VE+kiW +hh/mZMXyGuGKuYexz8Tv5M3qzdqxNmhFObyJPk/Y7XgIoBtdyHLMkqYN5fnlUMtQ +gjuJv2wDBVKza1YhNdr1 +-----END CERTIFICATE----- diff --git a/tests/elab/async_ssl_certs/crl.pem b/tests/elab/async_ssl_certs/crl.pem new file mode 100644 index 000000000000..b2ea9cc7b710 --- /dev/null +++ b/tests/elab/async_ssl_certs/crl.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBbjBYAgEBMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMMCWxvY2FsaG9zdBcN +MjYwODE3MTI0OTEwWhgPMjEyNjA3MjQxMjQ5MTBaoA4wDDAKBgNVHRQEAwIBATAN +BgkqhkiG9w0BAQsFAAOCAQEAYnbANtNZW0/FBkZQjNDa/mBqDNDYh4uAQWH0zkCm +kVGRjMNMB33J4XyaVlOJ7/I5TbrCQGzEAtFqzF5WlPFZEUNp/Ve/5CCSXUbrX94l +sst48shAoGlvApU1BEI//NppOJTr4PWxkfh8GhWMfU+EHnvghsnKIbAbFMnBV/hJ +dz+w64s6h43oAf5g3WpwhK9XHm6s3IljrDNwmtfEgvR+OHonZPMIOWu3YSZiftl0 +VytTNnLx5QuvEjasZVFXX33llsM6zkXrtLGSjkpdvWFmR6AtmJ0NZwccD/BBrCTw +8NudNxrvIwRUNvNa+OZTB49hfp4OVIvnDcr/R4ii80O/og== +-----END X509 CRL----- diff --git a/tests/elab/async_ssl_certs/eckey.pem b/tests/elab/async_ssl_certs/eckey.pem new file mode 100644 index 000000000000..3b23fa9bddf8 --- /dev/null +++ b/tests/elab/async_ssl_certs/eckey.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQghGZua23+ku595fBF +kULMnWb6u5NS+bDRb0qQw5kUo7uhRANCAAQI0CBXJc4VPetl+b628Yvj52aN1r88 +OWg9CdCMEJ2DnQXil9s3tBh0Wgsch5mWLaylaRdUcxeZwkzsgPrFjDN1 +-----END PRIVATE KEY----- diff --git a/tests/elab/async_ssl_certs/emptypwkey.pem b/tests/elab/async_ssl_certs/emptypwkey.pem new file mode 100644 index 000000000000..b349a6d946d7 --- /dev/null +++ b/tests/elab/async_ssl_certs/emptypwkey.pem @@ -0,0 +1,30 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQSMe7Ss5ZImymPYZ6 +tfMw7QICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEK/Yq1PS40HsgfwK +EvdzDDgEggTQRgtk/fYVAYO1ykpinPLAsLCRtxeu6fiqdLgvDKwOF8SnynGIRxQg +L9kBxYZzWRpoqwoZgOrCw6HK81y+PwIvVoN+gHp01ushWPt5W121DiMnhdLsyEUf +HWv4v5VyHuQoXb7tgUUdG0Rc3JUGiH+kYpC5acHvssX0dBNVMc1Vf3i+LhO2uUL/ +HsnNt5vYqi01cEQq67mSeQ6RRGEvAmJ944ktGvwjDFlKrg7S86Lplg/wCDuMLRh+ +D5HhchLmWjWyPbcCh9/47GE2FjRvcmDDC1bbdcp1hwsBQkqE8utJ2DYS8QJp4WUA +4keYnxMpHWkJ9yEW/H/30IV40bzvsIGX/hdhzv5yO4TF2AKI5Zn5rhIK88yfbqqh +r1OLIm8Nxk3nRY1uVlvg6siXNaevKe8sv5DKPexnMwRQbuKlcBZHOj+yFODllExu +Vfq3tBqRkXK648hfDNRblM68y3fT9rFCUguUNb0q+DX+EY/l4ZeUiGgmMe25JwtC +RT1c2RQ09FP5cT2KAOf2VJA43vf4Xs6a/lpYnVybyLXA+0AMWM2qLr/G5DeN6+fq +sASQUCDvmwxhMoIEcG6uyNntKMtUZAd9FhuRvkqM7pW3jTGtUMVj9mj10dnPTQB1 +IpT0IqcfVMI1nU7MeEwGgaHW6KuJkj+f5m2pxojmBnfxCczijIVbsRWfoY9P4iKB +us1ZQa4lUxSiUGAbhIPE7usa01GkLbZOjnYeJDC640ReFSqTEzS0RKuz/2G4DoTD +ZvDKB4Kjfv80QLnIt4BWiG1oCsd+n8sVXuPd+vmcOnpDLdWCpQgONa0ivT+vyLAz +m2fN2Qsih4V8QPUeZi54YRrlESy2Syt29mep58OekU+lJhhxt+OXso50/MDwgkiS +ZhaQ09BPiHMmXKk0smaZMoc78h4LuX3EgK4JKyvLxPmHtykbFsPEx+rPYOqvLATf +quyYKx/TEFuwS1DfuzBfggEXkT2L0HRaQNuTzyvr9BQ9C1ASGREe8h1AP8Mzjpqc +yjNEovSOvDkD5zhVdLPU0tBXl5T7/vZd21i0/AAkB/czIg4FHh7E626JHmPYkuXV +gDqLHGCVXl+I7dWZqV4+DHLqTdHIa2ZN88q0ttpf8xoyaO4q5igNuw/BIVhWBLz5 +bzFTUbMNYOEYyovQcZOXdm6Fo14MOJ/SUypTeP8eFLq2lsaa56AujRXEgV8zCFCx +wj4Pu14Q9A2eXlwYmRnE2TbXxZci8sUi1jDLlnyjvgYYJ5TkyxhS+5cXrS3/W8VR +YCPbyPjQO+42YSveIIUruJFfhJ2qOZPchmIJqaPaFa5Zr0e5SAv6t7uwquI9FkGY +SNQfRlrkVFDufZaUdbLzLstIae2JWOxi9ZxNWLhOREtWnUvwU6jONN23obJAEDR7 +FU1mKxC5vmJjudYS/advxvghV1WKTxvLHGVczkDKEDrax7OJflCOPfi92nt25IRc +DzmOULeV8AeNkNH52k1Dy+qjMhMFA3NnX1Jfs2/4TtsvdDBFhWQqRb1jxRQg1e85 +xZnKLqCej2e1d2lN0OD/FMCitZAfWF+1SneDzKVtmafsiZsvQmouACEw/MaGAzfR +9kQkQJgW+D6PqDz0l04PjNsx/yXXmG5XVl+SOhg8wi1/y+D8MKIUACk= +-----END ENCRYPTED PRIVATE KEY----- diff --git a/tests/elab/async_ssl_certs/enccert.pem b/tests/elab/async_ssl_certs/enccert.pem new file mode 100644 index 000000000000..d0bcd783452f --- /dev/null +++ b/tests/elab/async_ssl_certs/enccert.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +Proc-Type: 4,ENCRYPTED +DEK-Info: DES-EDE3-CBC,0102030405060708 + +1SJtlSJSeUM3VYOB2tnDAfkc+2wXK4F+LKusyjJZIt1brdNy88s3srU74zlqUXES +BnF+/muCKthRyprrzncWx+Ac0Oyb89C3i9q30FV4Ly3pSvwnomCjuYEBqWRvEALt +STz2yxd71pZRItMe75VUjbcGiGa3Viev9SoS2KUuu9zEtk/Y7TkhDrWg/WSmtK8o +Ua1m5ZZQ1YNGtxrE/Za6YnC+RAzUzdpnetGTYQyWe0mMZAxbKuf9mXzgnO3Gxuwb +yVhWltv/E/TdBNkddIDd+miJ6plFZXcdDx1zXmH/2oKXR4CcSFd8a2UHZONTM4RU +88AnS4UFIYFy6dcfcPaIeTSyG+wd8nc2jUCn2Ih5tVlGej9FCy4r+Ah2EiYuTNxY +ai1L2DXrky7hgdbvZkiK1V6aDtLvxmqUDcqdo7j3Fbox+u26uUikJdQGELmQ89ou +0gw4CXoeFuo03k4c0kw6gxvz+ChUhS79jkAcnR4bUSRMpctIi8EgmguA0q6kPSsF +udtgGCf547A47u59J/SRrV3HNJYkka6L0iFFPlVlvzyvXEw/zajwlvBTd6reW79J +1SXdOfLvSA55nKfV622W9IhFlIMoqOuF+gcbH5z4c8bNvw2fydCdBW6FUku/t/4q +HkYci6NAdMtBJzonzwJpQA3EsVQo4818qzm7oyNOjZA3phJbQUG8gEVO17jNK+v7 +fpFnjFDMR55feu5OMcCdlihlriPB8v4YEt4zaEgXjMyScl5sAVRRB3ssb5JI63f9 +lxJq0k8W5nBaJQmwMxYdvtJDkfGq9a7GowY9S9F6JkW/BxZ5/MnTghTq5RmQ/ylo +lIxLJ9H7Gj8LldL0Bg3NhBdaylBv//XrVQPg9ZRryNDWmzGReFa5G/t24aywB3zk +kjlyVz2nEhgfJaU7Jx9uM3WdH8WiuNVaHl4QP8d7Fl0+bO903Pzv2+pmthvuPWA+ +k8ZbluDJi6FMB5IOBZ9XxSlQDkm5R4Ji2xgPAhcD+o93ZA6eo+DMZcw7kGq6wP4a +6bf8h+6xMBaKHcaXxKT/ew== +-----END CERTIFICATE----- diff --git a/tests/elab/async_ssl_certs/enckey.pem b/tests/elab/async_ssl_certs/enckey.pem new file mode 100644 index 000000000000..291234a758cd --- /dev/null +++ b/tests/elab/async_ssl_certs/enckey.pem @@ -0,0 +1,30 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQ4r90nr4TGuyNM5lg +rrm25QICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEOymnMvrb8PdBcht +uX4Oz2QEggTQjvshSWTucNIVX5GJoNgWj2EVV+PmbnJ0gWITNL6PS5TY5X/uDGfL +X3ULXRy6F1Ypuk6yBfOgRWQI267BEc4eEYe0ECLqCa+5ebpV6OCJv8WR7urxBVF0 +lF9K4abWJOaExvK6TT2iIU+4aYvckXpJrss6fdMZ4y8Ez9GAMaCaw4x565/6ej3N +HW5ezxpXItoPkhPdHyWIJWGqgGK/RVro23UnteqsPpl5K5x2NLUoh1BQm74W8cdL +OoSi9c7yDOy7jaaPwwovcpJJkpE0PhTxrUUU2NNEvcK9bvPBkkZxFYtPPTGmErOH +QGF/43peHVD3yF2E/Z3YHN0xXyqqkf7lB2nuIl2rveg8JzvyZunGoYjoNTj6BlqO +FEIE8iWUAtdn8RutjdujSnv2fLGhDcHtBwVzT+Ia6poL+RW4TnBR9J3dIWVWmw0H +WNuOJzia3SRQUbHyC3M+suxqZ629Z0YQBc2cn2Tck+JgSx3o2/ILHpVwP96zZzRr +Al2Z/O4/BZnQ8VdaKYuacpkX3N76nkMOF+10Mr2Qy+pFjQRgFhMQVcaHfIwCv9BC +MgfIgwQi8+gY0wgNEM0ItYLABRg1f8/wmh2g4L2Da7RsKJSRwpFBXvHG4PlPfxNH +SUQKKPH+cwgcA6HOHHd3O7HoQRgnwNXNc5wfpaNxyNWHKajvbgo18tQrXu7/45ax +M4GS9eq6v8NDFqhz72iKNNaN//jRSCH/MKzLU/oMv/pDBCovnAc+HwA4G3+NE+/6 +jE1I+p5SEvJEiMWzsfFw8MLeKDOa3kvBB5rutV2/jV/XW7LqgZwA7XZqkHyIoPYt +BbL4VrJs9xMwRQOQmYqmumv1g1/TOXn4QQbjJtDcfmYJhJ0I8LXzOKpuAFZNn3E1 +SX+V969YzfGt10toZ2/kMeg0xzof9d0nrOa+jgzoAChIBsm8Oz93S1zhnJRJflEt +d+jI+ceL0N97DkLUlEz0np/qYwYaoeDPKU0L50j0yesywyc2xuMshpL/2xt0QN9J +PjpwVIMp0w1J9i5QYdaH4twGk5vMFSa44AZ8844RW3FSf5quqz5Og5/TPK6ZNsQi +3VqUfGSCutdB8bQ7yb/Q7rQJKJyFRDDZUiicDUmkhAr00Xa/HlPp76mQw85E2Xrj +/L+PMPJnMcGBlm4MkCAQNjimRBka5dlVDUzi8HmgvOkoaJag4vCf4Ipr52JbOBTu +3Z+aQyO0BCdncKVzYHXZ1XpafRjZb6YHJDJcJ/NneEKY7EzRCTAwiwMTlK9dNU+M +KcTrfECP/HG7wTiPo7X3MmsZ8WDOSTaLF5WbgCohY2sK+hWUXBNvL3RLs/bpzNzq +GSdiE9eftNkeBCKG72cUkMpsWJW7+ZJp1BE63JKKs1qfan+1iwVWYjcXd9rJn/FZ +RDkyvQVc+EE5xmq4kqXPG2BuiBmp/SfDpV1EfJaTIi10YBmUf2mlmrvBH07GhHkx +fjDdNnP9rvKpHNanVgSObUT5bAmER8u8x83ZtNzZWpPe8ZNNb3ERjQ7s7Qpp6a9o +wCLUepYLb0JtKsa7OliL6XtboyuNg9suqygAKmxMSqe9q90rkS0CiGv4J+KginkN +MowNIidl/EhUN7UgyEYoLlPFlUMDspxH9yJLVEqFfIgrv2JcqeLbwdU= +-----END ENCRYPTED PRIVATE KEY----- diff --git a/tests/elab/async_ssl_certs/expired.pem b/tests/elab/async_ssl_certs/expired.pem new file mode 100644 index 000000000000..bcd1bcd32005 --- /dev/null +++ b/tests/elab/async_ssl_certs/expired.pem @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICxDCCAaygAwIBAgIBYzANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAlsb2Nh +bGhvc3QwHhcNMjAwMTAxMDAwMDAwWhcNMjAwMTAyMDAwMDAwWjAUMRIwEAYDVQQD +DAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC7SwGf +Q+WLyJwnRlX37WMUEDT/YVZd0PV/6PPJSFx30z2vZnqMh9S7gQPvkYkon7qMtqF5 +jlJt3zDmddjuhhwHqeNj1htKnWPjhh8rc2DG7v9u/36O92fz2jKUn8qHGG80+SiW +4LkE8uXuC/ia0a1W03iT7rApICuSIgNrP5ZrXZ3pHxn4m7GxnOxm/5jt0SX3HQkR +V+VMEo0cGEq/8ZvmwnOOG14C/o/FxFw9zxw8pDTabvfLVxoHCMOu7UB3c0Hg6SzM +8cD/QefWQRLyD/rZIw34GcTs9IklWxJ0loqjY1q0c5p5991zRC2SqmM6vpAjc6dp +ijIAZvsycewlnY1bAgMBAAGjITAfMB0GA1UdDgQWBBRam+qywW30FsQlhzW2SV7d +Hs96NDANBgkqhkiG9w0BAQsFAAOCAQEAjaXKUdudCDoUKCswhiW3bZalzxwzDWQz +C18pqR2pyVSUCifwkgyGWDsX3UMwA9NiM3S/q10KqQXqHpFADvtD1vZsKpORDEmN +Wcgb5d7LteGU6Wsgadvy9kCiKj6jr7VkmMi6ixsLtNWgU2oPC4NLVsThWkltY2j0 +tGtjMok4hrmWyuPpk+2VMjbx09O/gY60Il4KnNoObRWV9lv8Fz1+Q0ARZygqzlrB +ZBQl6tdysiB34vR7KBCiqwq1h8p3FfsiPu7CS6zrRnrugk/h9AhNewf0mI6KHzPi +KYDraf9jUej4BG2cSeZARCWPavBZD/5y4gQPaXUtbXsv+yX87049nQ== +-----END CERTIFICATE----- diff --git a/tests/elab/async_ssl_certs/intermediate.pem b/tests/elab/async_ssl_certs/intermediate.pem new file mode 100644 index 000000000000..18a207dd905c --- /dev/null +++ b/tests/elab/async_ssl_certs/intermediate.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDEzCCAfugAwIBAgIBKjANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAlsb2Nh +bGhvc3QwIBcNMjYwODMxMjIxMjI2WhgPMjEyNjA4MDcyMjEyMjZaMB8xHTAbBgNV +BAMMFFRlc3QgSW50ZXJtZWRpYXRlIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEA16dHSIlSMRr18CnOIL6pJ1d/a9JPwAhIvdN7IrjQ4h5TNgSbWdEU +jTthMmc/rFClIibDT1QUkUEK7t3cMLdi4PCUmN/90XLP74LKxwf3xkRRh4BxYI/v +l19gccJ8sEOwYMLY0zTXTqw3hDuoKCWK4H3P7wTIDCR/qNXZxsHZFFg+/2W6WTO8 +CDwg4S507sp3KDB/CFHTFuohqU8ruTTkEdK4zAd66sNCsg+svLk8D03Ffud8wH5h +PpEPp9qdsMZFhPX+dEQAkzI2nO2SkDMSN/5BKhVG1CBh1QRojNS8ulWeqEqP//Xt +up20YIIUZIh15MCWtDiX7AVyKi4kYjVxlwIDAQABo2MwYTAPBgNVHRMBAf8EBTAD +AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUc9NBEBgBrwKY62s1HJ8KeMMO +l48wHwYDVR0jBBgwFoAUWpvqssFt9BbEJYc1tkle3R7PejQwDQYJKoZIhvcNAQEL +BQADggEBAA96MyWfF/QvDD91iyrQQrf/tsHyXoNXw4SFVSHlRjGzomMp/z0F8TJy +2eQvcfEZAc5KfngJJqyG+Z1PlHso36Atm8TyRLX/yeN2pbrIY1NxbSvNc5lXOxRF +o6ji+ljm6G3MKB8YLIU8FwdTMw+WoHRjGeqHQVEbJr/neKe9Kvt/SKnH8i7yoRWC +OQktGusU8Z+lbdJ9JoaHGbbg2d+qFSCMuBb8efbEHMCzmBKfHlvK31Sm6q8wIGFe +2TjaiIdHEq1M5OGPUSmI93fZU83bROxCNnj27QRV8z9nkctqHeUF0F5Pdo8/r7mL +L6gm0xIk5duKa2Kp7P7hSq+IOceIAyw= +-----END CERTIFICATE----- diff --git a/tests/elab/async_ssl_certs/key.pem b/tests/elab/async_ssl_certs/key.pem new file mode 100644 index 000000000000..556172331cda --- /dev/null +++ b/tests/elab/async_ssl_certs/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7SwGfQ+WLyJwn +RlX37WMUEDT/YVZd0PV/6PPJSFx30z2vZnqMh9S7gQPvkYkon7qMtqF5jlJt3zDm +ddjuhhwHqeNj1htKnWPjhh8rc2DG7v9u/36O92fz2jKUn8qHGG80+SiW4LkE8uXu +C/ia0a1W03iT7rApICuSIgNrP5ZrXZ3pHxn4m7GxnOxm/5jt0SX3HQkRV+VMEo0c +GEq/8ZvmwnOOG14C/o/FxFw9zxw8pDTabvfLVxoHCMOu7UB3c0Hg6SzM8cD/QefW +QRLyD/rZIw34GcTs9IklWxJ0loqjY1q0c5p5991zRC2SqmM6vpAjc6dpijIAZvsy +cewlnY1bAgMBAAECggEADV4RnBHnAL6NMpppCVx2jViIx89lMCX5V6tDNxMEkoLP +rMSmK4CIVOek5cTf4rffwypHxRq81F2xKkmv9Xo55uwfsCD4aq9oETWh5OKDvj8R +mRUALekHkNZ6dLQg6tp6GXBNDtO0MN+7PG27TSV49zD5sqk/Bnhm07O8xbtQm5IA +LheZd4GPqBdTIf1krFNP86Th4X+DKrnuNmiXS4lDuhH/rheRfTQ4VK/srnrj//Zw +I+AeXwnch25CByXp+CoSHpwZGPYzfknZBjmkh1QbwMR/ivjA954BxbVsWl+/cAYh +7+MEvkrInJYqbwSVWQrVrKjRuHqI0OcvymBbGXoaHQKBgQDg+HBLB0Tt+mQqX+Kb +eo0ymK+V2Y0kprlVthTHPJHpA+zMcEBJWjlz0IpdfkjKAMrVoVJB4Fn8Y4Wj4Nzq +yV1AsZ/cHsH3NWnwiMaRvVTs7O60Vhs0G/I2lIx6T0dl4qWFjJQizrvMctNFVkvR +rh4tnGQnTMBViqKAB5CiW/o/dwKBgQDVIDNchhEAijUNJ60c4XcbrhGjlBmjDRyG +KfVM1LMQz9u20bZvOP/qL5wNHrlGqplOBUvD1o/J6b4ZIgzhWNB0WrPrBBi40pvv +9v7wCTZ3XfJ+KrGlWOfB0fPVs2kKjLd8b+1xoa7JM/RJIBmytXj3o9lS/6F7SkjH +0EQv086CPQKBgE83jCsPOzllMwIs01mWNMP9Oc7VVTrzrk09GWHytRpM9IQkfq6V +o6dhZmd3gWAIGWRSMunZezZBQRysoH3YPAr8wOK8veYzm8NEFk/ZUF9BKui7bUbT +FF4dvr2OzwBUZ554Gu2KyFw8jqJaucXyvtOmvymLgCpe78uPXmGda6gPAoGBAL3y +5xPtgTXD+ChzVjzJTkjjSWFLW9YQl32T48bIQ5gWSbKVEk3qtVvZdvHSkjrDTcNV +wQMYNis1InJwAJ7Pc2pgdL5fdlEzlDu5Hdp9u4eDud5s2suNg3EhWHr8XgBDDj3f +2/ZMreUxYuXRsFWwm9HKvKTWpOund1pu6nbeBc3ZAoGAWKJkhw7KoELqiCpTn3If +7ZN64vgqkNacXfjzc4D5oJ2aqAPJsTBdJ14+VShecgc5Kn0QriP0mTU712/GiK08 +A0Xb02+1ouerqiUE+Ea++rZphkyC0g+MKcoCWFWKDmtJuC7vtCGLOeFuLgHahqqS +yIGPWTqB+JUmYpWBWIvu0Gg= +-----END PRIVATE KEY----- diff --git a/tests/elab/async_ssl_certs/key2.pem b/tests/elab/async_ssl_certs/key2.pem new file mode 100644 index 000000000000..ccf9868835ab --- /dev/null +++ b/tests/elab/async_ssl_certs/key2.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDXp0dIiVIxGvXw +Kc4gvqknV39r0k/ACEi903siuNDiHlM2BJtZ0RSNO2EyZz+sUKUiJsNPVBSRQQru +3dwwt2Lg8JSY3/3Rcs/vgsrHB/fGRFGHgHFgj++XX2BxwnywQ7BgwtjTNNdOrDeE +O6goJYrgfc/vBMgMJH+o1dnGwdkUWD7/ZbpZM7wIPCDhLnTuyncoMH8IUdMW6iGp +Tyu5NOQR0rjMB3rqw0KyD6y8uTwPTcV+53zAfmE+kQ+n2p2wxkWE9f50RACTMjac +7ZKQMxI3/kEqFUbUIGHVBGiM1Ly6VZ6oSo//9e26nbRgghRkiHXkwJa0OJfsBXIq +LiRiNXGXAgMBAAECggEAHO3QNq9RKswylMKO576b426t5bFjSF/0Hh7aBFjlkIe/ +4t2wV1agWqfODJxkIsH4vQGVLrWZQrkGde+mI5TeO02aqX1Wx7uOoFMbz6JGfz7X +7wTwcKMuYVCGmAwefOi/puNMgdyzS1b35ZG5J5WNsTq/Y5FxIovc4jG50ptbewpy +R1xl4lna0nI5mwnNmrNuz6gLHfNPrIygXCD4Rn1EIQXpGI9bI+gRcue7O7aR7j83 +2OTnC90Mg9Hv5JssqwC1dxwTg6DJ00VP+YqJrA53WK/2X72TboVE+Uojh/jC3vXt +G8UuunhcvAVm1gHntQeZhiPiHI5l+N2J2qXZjliKBQKBgQD84NSg/MgEwvGCbQbA +aEDDVqKZ3ued1bmKsegvBYuoxxPAyS8uWjQE+LsskLXSx7BghblV65ti4taiQiVv +iX8O/NPbqOUbsPUGCHjhJjpLOR3/3jtaXjEetPkjRaOMv0xsd6MhQiIbktqlyCnW +RwaxdAFeKyiSDfziuY/pSa8+owKBgQDaUM55kCTOVrfFKOoG43wafVxeystuz/7Q +OLijwkcRYZpvfMSJNkUX/IgUvSX4qs3ymuZu6HOwiqA2yIVugFejF8MD1b4oikQz +RxYQg5P0mynWSuCShhNEn4JjIu4waTL61/aqX3MysDWz68r3I7cCi1mS0ht+t9uq +u8wpAdZ0fQKBgApmGHhSTMtdVN8fKqLo5pjhzCf1saKc8ldQv5KHcNnM7fQEkesf +DHqT2+aWQNPdIFSnyxpMaQRk/ZyIic+PYOk13mRvCpTb7weDe60OjGEhhSlLczdh +HjX8DS61I2ebSkI/nTa16H8nx9P/ajEElLLhaVj8/1saNicAqHlYbVtvAoGBAMEm +4zbcWCQSxy5xv3Ruyfsp1JKte4VEEt8of/uqxHPVVeyzh7MaR9EsBT3MB49VlzbA +44VWthyI2az+hkc419CdElYPQtndUu/HQfdEYp/0s/Q7dStN4jhBo/uQCQrd2FPk +xQEByAsdqbXQtVcoyx8+KPbkW50mj/wjgUL6tEGFAoGBANFfxX7FFIAHokerNCQC +juGi/Vm4hfKe2GZ9wpNPv1XTJ8IjsQ//ou1Fd0HTkOzlllL7mdBzjOHN+wcB3FRp +20OHlJX7vP6pZr6Hov1UBgqQcaksloysecPiPJpGcjXv9aBu0kw2M+/19R0pkHF1 +9TR36Io5p4bDq/W+IfPgv9QI +-----END PRIVATE KEY----- diff --git a/tests/elab/async_ssl_certs/multisan.pem b/tests/elab/async_ssl_certs/multisan.pem new file mode 100644 index 000000000000..f394984d0d37 --- /dev/null +++ b/tests/elab/async_ssl_certs/multisan.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDSDCCAjCgAwIBAgIUT5RcMZXOiovpwJ8d+UGsA8mm6KwwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQYWxwaGEudGVzdC5sb2NhbDAgFw0yNjA3MDQxNzA3MzJa +GA8yMTI2MDYxMDE3MDczMlowGzEZMBcGA1UEAwwQYWxwaGEudGVzdC5sb2NhbDCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALtLAZ9D5YvInCdGVfftYxQQ +NP9hVl3Q9X/o88lIXHfTPa9meoyH1LuBA++RiSifuoy2oXmOUm3fMOZ12O6GHAep +42PWG0qdY+OGHytzYMbu/27/fo73Z/PaMpSfyocYbzT5KJbguQTy5e4L+JrRrVbT +eJPusCkgK5IiA2s/lmtdnekfGfibsbGc7Gb/mO3RJfcdCRFX5UwSjRwYSr/xm+bC +c44bXgL+j8XEXD3PHDykNNpu98tXGgcIw67tQHdzQeDpLMzxwP9B59ZBEvIP+tkj +DfgZxOz0iSVbEnSWiqNjWrRzmnn33XNELZKqYzq+kCNzp2mKMgBm+zJx7CWdjVsC +AwEAAaOBgTB/MB0GA1UdDgQWBBRam+qywW30FsQlhzW2SV7dHs96NDAfBgNVHSME +GDAWgBRam+qywW30FsQlhzW2SV7dHs96NDAPBgNVHRMBAf8EBTADAQH/MCwGA1Ud +EQQlMCOCEGFscGhhLnRlc3QubG9jYWyCD2JldGEudGVzdC5sb2NhbDANBgkqhkiG +9w0BAQsFAAOCAQEARAL62Nvmy0njSp9Zze3vFUkrnT0NnAfXZ7KdTLCtOAbhNWug +zm9ytVXI2mwMSoRWIKq6Mkl1LlrqYUBf/L37Bkkx1wcxtutJ02r2nzX3ahK1S706 +WVZhFOMeS1LJJR4IcHzJ7GaQogVascwqwA/2EAwg6eGQ6og6ZAwAFfo9NJxmq3P2 +99iApuVTp6rm621c8pnXQ9ag+L4VNMZFpCYZWckc/qw7wRhTD61uPKkd2U+msz0Z +Q/OYQ3nt3ZFRG55NLflRkrOqtdcwZnmUKyEcMDG8HFN1tryj6q95lh8O49DSK1Rw +TY2KaD8AAPmYvJhFxhNIVhdhq/UDisDKunY+vQ== +-----END CERTIFICATE----- diff --git a/tests/elab/async_ssl_certs/tradkey.pem b/tests/elab/async_ssl_certs/tradkey.pem new file mode 100644 index 000000000000..81ccf446f6ea --- /dev/null +++ b/tests/elab/async_ssl_certs/tradkey.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAu0sBn0Pli8icJ0ZV9+1jFBA0/2FWXdD1f+jzyUhcd9M9r2Z6 +jIfUu4ED75GJKJ+6jLaheY5Sbd8w5nXY7oYcB6njY9YbSp1j44YfK3Ngxu7/bv9+ +jvdn89oylJ/KhxhvNPkoluC5BPLl7gv4mtGtVtN4k+6wKSArkiIDaz+Wa12d6R8Z ++JuxsZzsZv+Y7dEl9x0JEVflTBKNHBhKv/Gb5sJzjhteAv6PxcRcPc8cPKQ02m73 +y1caBwjDru1Ad3NB4OkszPHA/0Hn1kES8g/62SMN+BnE7PSJJVsSdJaKo2NatHOa +effdc0QtkqpjOr6QI3OnaYoyAGb7MnHsJZ2NWwIDAQABAoIBAA1eEZwR5wC+jTKa +aQlcdo1YiMfPZTAl+VerQzcTBJKCz6zEpiuAiFTnpOXE3+K338MqR8UavNRdsSpJ +r/V6OebsH7Ag+GqvaBE1oeTig74/EZkVAC3pB5DWenS0IOraehlwTQ7TtDDfuzxt +u00lePcw+bKpPwZ4ZtOzvMW7UJuSAC4XmXeBj6gXUyH9ZKxTT/Ok4eF/gyq57jZo +l0uJQ7oR/64XkX00OFSv7K564//2cCPgHl8J3IduQgcl6fgqEh6cGRj2M35J2QY5 +pIdUG8DEf4r4wPeeAcW1bFpfv3AGIe/jBL5KyJyWKm8ElVkK1ayo0bh6iNDnL8pg +Wxl6Gh0CgYEA4PhwSwdE7fpkKl/im3qNMpivldmNJKa5VbYUxzyR6QPszHBASVo5 +c9CKXX5IygDK1aFSQeBZ/GOFo+Dc6sldQLGf3B7B9zVp8IjGkb1U7OzutFYbNBvy +NpSMek9HZeKlhYyUIs67zHLTRVZL0a4eLZxkJ0zAVYqigAeQolv6P3cCgYEA1SAz +XIYRAIo1DSetHOF3G64Ro5QZow0chin1TNSzEM/bttG2bzj/6i+cDR65RqqZTgVL +w9aPyem+GSIM4VjQdFqz6wQYuNKb7/b+8Ak2d13yfiqxpVjnwdHz1bNpCoy3fG/t +caGuyTP0SSAZsrV496PZUv+he0pIx9BEL9POgj0CgYBPN4wrDzs5ZTMCLNNZljTD +/TnO1VU6865NPRlh8rUaTPSEJH6ulaOnYWZnd4FgCBlkUjLp2Xs2QUEcrKB92DwK +/MDivL3mM5vDRBZP2VBfQSrou21G0xReHb69js8AVGeeeBrtishcPI6iWrnF8r7T +pr8pi4AqXu/Lj15hnWuoDwKBgQC98ucT7YE1w/goc1Y8yU5I40lhS1vWEJd9k+PG +yEOYFkmylRJN6rVb2Xbx0pI6w03DVcEDGDYrNSJycACez3NqYHS+X3ZRM5Q7uR3a +fbuHg7nebNrLjYNxIVh6/F4AQw4939v2TK3lMWLl0bBVsJvRyryk1qTrp3dabup2 +3gXN2QKBgFiiZIcOyqBC6ogqU59yH+2TeuL4KpDWnF3483OA+aCdmqgDybEwXSde +PlUoXnIHOSp9EK4j9Jk1O9dvxoitPANF29NvtaLnq6olBPhGvvq2aYZMgtIPjCnK +AlhVig5rSbgu77Qhiznhbi4B2oaqksiBj1k6gfiVJmKVgViL7tBo +-----END RSA PRIVATE KEY----- diff --git a/tests/elab/async_ssl_certs/weakcert.pem b/tests/elab/async_ssl_certs/weakcert.pem new file mode 100644 index 000000000000..87d59a1ff1fa --- /dev/null +++ b/tests/elab/async_ssl_certs/weakcert.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBgTCCASugAwIBAgIUUh8zV3Ot1AzqtF6hLxvpOhb0e7AwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDgxODE1MzIyNVoYDzIxMjYw +NzI1MTUzMjI1WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwXDANBgkqhkiG9w0BAQEF +AANLADBIAkEA4D1MjlOXaLr8HamPDGwJLeoQ4JDFYdSkNB81DOAbBgvjDlVTwJeF +TU4cZyBSQYtMP191GRdMuGLWMnR/knQtZQIDAQABo1MwUTAdBgNVHQ4EFgQUAv+R +T286YKRTO5l4TNK2r4GyY6AwHwYDVR0jBBgwFoAUAv+RT286YKRTO5l4TNK2r4Gy +Y6AwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAANBAK+5wtqiMSn0mTBS +JTF7/1vdYd0K9Yu+MT8zO1WlegFr42LgmFb/4TAMAlytX74LsRWUaXGPPJW+Co3/ +0sTyEDg= +-----END CERTIFICATE----- diff --git a/tests/elab/async_ssl_certs/wildcard.pem b/tests/elab/async_ssl_certs/wildcard.pem new file mode 100644 index 000000000000..717245d75d0e --- /dev/null +++ b/tests/elab/async_ssl_certs/wildcard.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDNjCCAh6gAwIBAgIUFf/bf1yMwzDScis3gQg4g6aMoaYwDQYJKoZIhvcNAQEL +BQAwFzEVMBMGA1UEAwwMKi50ZXN0LmxvY2FsMCAXDTI2MDcwNDE3MDczMloYDzIx +MjYwNjEwMTcwNzMyWjAXMRUwEwYDVQQDDAwqLnRlc3QubG9jYWwwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQC7SwGfQ+WLyJwnRlX37WMUEDT/YVZd0PV/ +6PPJSFx30z2vZnqMh9S7gQPvkYkon7qMtqF5jlJt3zDmddjuhhwHqeNj1htKnWPj +hh8rc2DG7v9u/36O92fz2jKUn8qHGG80+SiW4LkE8uXuC/ia0a1W03iT7rApICuS +IgNrP5ZrXZ3pHxn4m7GxnOxm/5jt0SX3HQkRV+VMEo0cGEq/8ZvmwnOOG14C/o/F +xFw9zxw8pDTabvfLVxoHCMOu7UB3c0Hg6SzM8cD/QefWQRLyD/rZIw34GcTs9Ikl +WxJ0loqjY1q0c5p5991zRC2SqmM6vpAjc6dpijIAZvsycewlnY1bAgMBAAGjeDB2 +MB0GA1UdDgQWBBRam+qywW30FsQlhzW2SV7dHs96NDAfBgNVHSMEGDAWgBRam+qy +wW30FsQlhzW2SV7dHs96NDAPBgNVHRMBAf8EBTADAQH/MCMGA1UdEQQcMBqCDCou +dGVzdC5sb2NhbIIKdGVzdC5sb2NhbDANBgkqhkiG9w0BAQsFAAOCAQEAc2uKoGPh +24uBrLtisOjXoQPmrU8LaKxKRG9+0PHKnfVHMB8barkefXDTrMSNB3zNKw3YI29Y +QDeWkD+MqtfkSyQICgMuwVKcmQUvddc056h5L37+pJhr6BoJMcI/d78w6GFgwbjo +otyzj1JI8YcBhAjbajzFwf762OuNj1baOh06uwnSenews3KnGJNeEM8xpuwdSthw +Tl+VbiRLuF2nDXjmwbWphil+3iP/0AVHUb8JLLMEypZoBLDoPAeqqVwxwxMYt2+K +T8LHCFILbr/nuBPi2/MfZ3sYSPHihu9duaHByimWueplB7lpOJZG3tnZIYZ0xxoh +S4Ssy7Qdt4wZuw== +-----END CERTIFICATE----- diff --git a/tests/elab/async_ssl_context.lean b/tests/elab/async_ssl_context.lean new file mode 100644 index 000000000000..48367f11aea0 --- /dev/null +++ b/tests/elab/async_ssl_context.lean @@ -0,0 +1,726 @@ +import Std.Internal.SSL +import Lean + +/-! +Tests for `Std.Internal.SSL.Context`: TLS context creation and configuration. + +This is the Context-only layer split out of #13112 (`TCP.SSL`); session and socket +behaviour are exercised in separate test files. +-/ + +open Std.Internal.SSL + +open Lean in + +elab "include_cert% " path:str : term => do + let dir := (System.FilePath.mk (← readThe Core.Context).fileName).parent.getD ⟨"."⟩ + return mkStrLit (← IO.FS.readFile (dir / path.getString)) + +def testCertPEM : String := include_cert% "async_ssl_certs/cert.pem" +def testKeyPEM : String := include_cert% "async_ssl_certs/key.pem" +def testWildcardCertPEM : String := include_cert% "async_ssl_certs/wildcard.pem" +def testMultiSANCertPEM : String := include_cert% "async_ssl_certs/multisan.pem" +def testCorruptCertPEM : String := include_cert% "async_ssl_certs/corrupt.pem" + +-- Validity window entirely in 2020. Building a context parses certificates without checking their +-- validity period, so this is expected to load like any other. +def testExpiredCertPEM : String := include_cert% "async_ssl_certs/expired.pem" + +-- `key.pem` in the traditional (RFC 1421) encoding rather than PKCS#8. The distinction matters: +-- `PEM_X509_INFO_read_bio` drops a `BEGIN PRIVATE KEY` block entirely, but yields an entry with no +-- certificate for `BEGIN RSA PRIVATE KEY`, which is the case the loader has to skip. +def testTraditionalKeyPEM : String := include_cert% "async_ssl_certs/tradkey.pem" + +-- Matches none of the certificates above, so it is only good for provoking a key/cert mismatch. +def testUnrelatedKeyPEM : String := include_cert% "async_ssl_certs/key2.pem" + +-- A P-256 key, so the mismatch against the RSA certificates is one of algorithm rather than value. +def testECKeyPEM : String := include_cert% "async_ssl_certs/eckey.pem" + +-- `key.pem` behind a passphrase, which OpenSSL asks for on the terminal unless it is told not to. +def testEncryptedKeyPEM : String := include_cert% "async_ssl_certs/enckey.pem" + +-- `key.pem` encrypted under an *empty* passphrase, which a password callback reporting a zero-length +-- passphrase rather than a failure decrypts instead of rejecting. +def testEmptyPassphraseKeyPEM : String := include_cert% "async_ssl_certs/emptypwkey.pem" + +-- `cert.pem` as an RFC 1421 encrypted `CERTIFICATE` block. Unlike an encrypted key, this is +-- decrypted in place while the bundle is read, so it reaches the password callback through a +-- different path in every constructor. +def testEncryptedCertPEM : String := include_cert% "async_ssl_certs/enccert.pem" + +-- Self-signed under a 512-bit RSA key. It parses like any other certificate and is turned away by +-- the security level instead, which is a different failure from unreadable PEM. +def testWeakCertPEM : String := include_cert% "async_ssl_certs/weakcert.pem" + +-- Signed by `cert.pem` rather than by itself, so it is a CA that no chain can terminate at. It is +-- the only fixture here whose issuer differs from its subject. +def testIntermediateCertPEM : String := include_cert% "async_ssl_certs/intermediate.pem" + +-- A CRL: the non-certificate bundle entry that is not a private key. It is what separates "this +-- bundle holds no certificates" from "this bundle could not be read". +def testCRLPEM : String := include_cert% "async_ssl_certs/crl.pem" + +-- Three distinct certificates in one file, the shape of a real CA bundle. +def testBundlePEM : String := testCertPEM ++ testWildcardCertPEM ++ testMultiSANCertPEM + +/-! +Every file the `PEM.file` cases need, written once into one temporary directory. The in-memory +constants above are the same material; a fixture exists only where a test needs a *path*. +-/ + +structure Fixtures where + cert : String + key : String + /-- Matches no certificate here, so pairing it with `cert` is a key/certificate mismatch. -/ + unrelatedKey : String + ecKey : String + encKey : String + emptyPwKey : String + encCert : String + expired : String + weak : String + intermediate : String + /-- Text with no PEM armour at all. -/ + junk : String + corrupt : String + /-- A valid leaf followed by a corrupt second certificate: a chain whose *intermediate* is bad. -/ + chain : String + empty : String + dir : String + unreadable : String + /-- A path that treats a regular file as if it were a directory, which the OS refuses. -/ + nonDirParent : String + +def mkFixtures : IO Fixtures := do + let root ← IO.FS.createTempDir + + let write (name contents : String) : IO String := do + let path := toString (root / name) + IO.FS.writeFile path contents + return path + + let cert ← write "cert.pem" testCertPEM + let key ← write "key.pem" testKeyPEM + let unrelatedKey ← write "key2.pem" testUnrelatedKeyPEM + let ecKey ← write "eckey.pem" testECKeyPEM + let encKey ← write "enckey.pem" testEncryptedKeyPEM + let emptyPwKey ← write "emptypwkey.pem" testEmptyPassphraseKeyPEM + let encCert ← write "enccert.pem" testEncryptedCertPEM + let expired ← write "expired.pem" testExpiredCertPEM + let weak ← write "weak.pem" testWeakCertPEM + let intermediate ← write "intermediate.pem" testIntermediateCertPEM + let junk ← write "junk.pem" "this is not pem\n" + let corrupt ← write "corrupt.pem" testCorruptCertPEM + let chain ← write "chain.pem" (testCertPEM ++ testCorruptCertPEM) + let empty ← write "empty.pem" "" + + let dir := toString (root / "subdir") + IO.FS.createDir dir + + let unreadable ← write "secret.pem" testCertPEM + IO.setAccessRights unreadable { user := { read := false, write := false, execution := false } } + + return { cert, key, unrelatedKey, ecKey, encKey, emptyPwKey, encCert, expired, weak, + intermediate, junk, corrupt, chain, empty, dir, unreadable, + nonDirParent := toString (System.FilePath.mk cert / "ca.pem") } + +-- Asserts that an IO action fails with exactly `expected` as its message. +def assertErrorMessage (label expected : String) (act : IO Unit) : IO Unit := do + match ← act.toBaseIO with + | .ok _ => throw <| IO.userError s!"{label}: expected failure, but it succeeded" + | .error e => + let actual := toString e + unless actual == expected do + throw <| IO.userError s!"{label}:\nexpected error: {expected}\nactual error: {actual}" + +-- For a failure whose exact wording depends on the platform's C library or on OpenSSL's ambient +-- configuration. The set is spelled out so an unexpected *third* message still fails the test. +def assertErrorMessageOneOf (label : String) (expected : List String) (act : IO Unit) : IO Unit := do + match ← act.toBaseIO with + | .ok _ => throw <| IO.userError s!"{label}: expected failure, but it succeeded" + | .error e => + let actual := toString e + unless expected.contains actual do + throw <| IO.userError s!"{label}:\nexpected one of:\n\ + {String.intercalate "\n --- or ---\n" expected}\nactual error: {actual}" + +-- A missing file reaches OpenSSL's error queue as an `ENOENT` entry, which is turned back into the +-- corresponding `IO.Error` on the offending path. +def missingFileError (path : String) : String := + s!"no such file or directory (error code: 2)\n file: {path}" + +-- Failures with no `errno` behind them (unparsable PEM, key/cert mismatch) are reported as `EINVAL` +-- plus a description of what went wrong with the material on the offending path. +def malformedFileError (path detail : String) : String := + s!"invalid argument (error code: 22, {detail})\n file: {path}" + +-- A path is rejected before it reaches OpenSSL if it cannot be passed as a C string. +def nulByteError (path : String) : String := + s!"invalid argument (error code: 22, string contains NUL bytes)\n file: {path}" + +-- The in-memory variants report the same way, but have no path to attach. +def malformedPEMError (detail : String) : String := + s!"invalid argument (error code: 22, {detail})" + +/-! +The CA loader reports one message per failure regardless of where the material came from: the +`file:` field the error already carries is what says which source it was. +-/ + +def caUnreadable : String := "could not read PEM CA certificates" + +def caNoCerts : String := "the CA material contains no certificates" + +def caNoSelfSigned : String := + "the CA material holds no self-signed certificate, so no chain can terminate in it (supply the \ + root, or allow partial chains to anchor at an intermediate)" + +-- Context creation and configuration (smoke test). +def testContextCreation (f : Fixtures) : IO Unit := do + let _serverCtx ← Context.Server.mk { cert := .file f.cert, key := .file f.key } + + -- Empty CA with `verifyPeer := false` disables verification without parsing any CA material. + let _clientCtx ← Context.Client.mk { verifyPeer := false } + + -- Non-empty CA file with `verifyPeer := true` exercises the additive trust path: the system + -- roots plus the supplied CA. + let _clientCtx2 ← Context.Client.mk { ca := some (.file f.cert) } + + -- A non-empty CA path with `verifyPeer := false` is accepted, but the CA file is not parsed. + let _clientCtx3 ← Context.Client.mk { ca := some (.file f.cert), verifyPeer := false } + + -- Defaults: no CA file, peer verification against the system trust anchors. + let _clientCtx4 ← Context.Client.mk + + -- The same anchors supplied in memory rather than by path. + let _clientCtx5 ← Context.Client.mk { ca := some (.text testCertPEM) } + +-- An absent CA with `verifyPeer := true` falls back to the platform trust anchors and succeeds. +def testMkFromPEMEmptyFallsBack : IO Unit := do + let _clientCtx ← Context.Client.mk {} + +/-! +`trustSystemRoots := false` narrows the store to the supplied CA, which is what pinning against a +private authority needs. The store a context starts with is empty, so excluding the platform anchors +without naming a CA would leave nothing to verify against — a context that could never complete a +handshake. That is refused at construction instead of at connection time. +-/ + +def noAnchorsError : String := + malformedPEMError "no trust anchors: peer verification is on, the platform trust anchors are \ + excluded, and no CA certificate was given" + +def testPinnedToSuppliedCA (f : Fixtures) : IO Unit := do + let _clientCtx ← Context.Client.mk { ca := some (.file f.cert), trustSystemRoots := false } + let _clientCtx2 ← Context.Client.mk { ca := some (.text testCertPEM), trustSystemRoots := false } + let _clientCtx3 ← Context.Client.mk { ca := some (.text testBundlePEM), trustSystemRoots := false } + +def testPinningRejectsEmptyCA : IO Unit := do + assertErrorMessage "pinned with no CA at all" noAnchorsError + (discard <| Context.Client.mk { trustSystemRoots := false }) + +-- `ca := some` is a claim that anchors were supplied, so empty material is a bundle that holds no +-- certificate rather than an absent one. That is a different diagnosis from the case above, and the +-- more specific of the two. +def testPinningRejectsEmptyCAMaterial : IO Unit := do + assertErrorMessage "pinned to an empty CA string" (malformedPEMError caNoCerts) + (discard <| Context.Client.mk { ca := some (.text ""), trustSystemRoots := false }) + +/-! +A trust anchor has to be a certificate chain building can terminate at, which by default means a +self-signed one. Pinning to nothing but intermediates therefore describes a context that could never +verify anything; `allowPartialChain` is what makes it verify, and without it the configuration is +refused where the mistake is rather than at every handshake. +-/ + +def testPinningRejectsIntermediateOnly (f : Fixtures) : IO Unit := do + assertErrorMessage "pinned to an intermediate PEM" (malformedPEMError caNoSelfSigned) + (discard <| Context.Client.mk + { ca := some (.text testIntermediateCertPEM), trustSystemRoots := false }) + + assertErrorMessage "pinned to an intermediate CA file" + (malformedFileError f.intermediate caNoSelfSigned) + (discard <| Context.Client.mk { ca := some (.file f.intermediate), trustSystemRoots := false }) + +-- `allowPartialChain` is the opt-in that makes an intermediate anchor a chain, so the same material +-- is accepted once it is set. +def testPinningToIntermediateWithPartialChain (f : Fixtures) : IO Unit := do + let _clientCtx ← Context.Client.mk + { ca := some (.text testIntermediateCertPEM), trustSystemRoots := false, + allowPartialChain := true } + + let _clientCtx2 ← Context.Client.mk + { ca := some (.file f.intermediate), trustSystemRoots := false, allowPartialChain := true } + +-- A bundle pairing the root with the intermediates beneath it terminates at the root, so the order +-- the two appear in does not matter. +def testPinningAcceptsRootWithIntermediate : IO Unit := do + let _clientCtx ← Context.Client.mk + { ca := some (.text (testIntermediateCertPEM ++ testCertPEM)), trustSystemRoots := false } + + let _clientCtx2 ← Context.Client.mk + { ca := some (.text (testCertPEM ++ testIntermediateCertPEM)), trustSystemRoots := false } + +-- Alongside the platform anchors an intermediate is redundant rather than fatal, so the check fires +-- only where the supplied material is the sole source of anchors. +def testIntermediateAllowedBesideSystemRoots : IO Unit := do + let _clientCtx ← Context.Client.mk { ca := some (.text testIntermediateCertPEM) } + +-- With verification off nothing is anchored at all, so the check does not apply. +def testIntermediateIgnoredWithoutVerification : IO Unit := do + let _clientCtx ← Context.Client.mk + { ca := some (.text testIntermediateCertPEM), verifyPeer := false, trustSystemRoots := false } + +-- With verification off there is no store to be empty, so excluding the platform anchors is not a +-- contradiction and `trustSystemRoots` is simply ignored. +def testPinningIgnoredWithoutVerification : IO Unit := do + let _clientCtx ← Context.Client.mk { verifyPeer := false, trustSystemRoots := false } + +-- Supplied CA material still has to yield a certificate. These reach the ordinary bundle-loading +-- failures rather than the "no trust anchors" one, which is what pins the check to the *absence* of +-- CA material rather than to it being unusable. +def testPinningStillValidatesCA (f : Fixtures) : IO Unit := do + assertErrorMessage "pinned to a malformed CA file" (malformedFileError f.junk caNoCerts) + (discard <| Context.Client.mk { ca := some (.file f.junk), trustSystemRoots := false }) + + assertErrorMessage "pinned to a CA string with no certificates" (malformedPEMError caNoCerts) + (discard <| Context.Client.mk + { ca := some (.text "not a certificate at all"), trustSystemRoots := false }) + +-- An unusable path is still rejected as a path, before the anchor bookkeeping is consulted. +def testPinningRejectsNulInCAFile : IO Unit := do + let caPath := "ca\x00.pem" + + assertErrorMessage "NUL byte in a pinned CA path" (nulByteError caPath) + (discard <| Context.Client.mk { ca := some (.file caPath), trustSystemRoots := false }) + +/-! +Server credentials may be supplied in memory rather than by path, for a certificate that comes from +a secret manager or is embedded in the binary. The two sources meet in a single loader as soon as +the material is open, so the cases below cover what is particular to `PEM.text`: no path to name in +a failure, and no NUL restriction. The diagnoses themselves are exercised through `PEM.file`. +-/ + +def testMkServerFromMemory (f : Fixtures) : IO Unit := do + let _serverCtx ← Context.Server.mk { cert := .text testCertPEM, key := .text testKeyPEM } + + -- The two sources are independent, so a file certificate pairs with an in-memory key and back. + let _serverCtx2 ← Context.Server.mk { cert := .file f.cert, key := .text testKeyPEM } + let _serverCtx3 ← Context.Server.mk { cert := .text testCertPEM, key := .file f.key } + + -- The whole chain is loaded from memory too, not just the leaf. + let _serverCtx4 ← Context.Server.mk + { cert := .text (testCertPEM ++ testWildcardCertPEM), key := .text testKeyPEM } + +-- In-memory failures report the same diagnoses as the path-based ones, without a path attached. +-- One unreadable case and one mismatch case pin both error shapes. +def testMkServerFromMemoryErrors : IO Unit := do + assertErrorMessage "malformed in-memory certificate" + (malformedPEMError "could not read a PEM certificate chain") + (discard <| Context.Server.mk { cert := .text "this is not pem\n", key := .text testKeyPEM }) + + assertErrorMessage "mismatched in-memory key" + (malformedPEMError "the private key does not match the certificate") + (discard <| Context.Server.mk { cert := .text testCertPEM, key := .text testUnrelatedKeyPEM }) + +-- A path cannot carry a NUL, but in-memory material is read with a length, so it can. +def testMkServerFromMemoryAcceptsNul : IO Unit := do + let _serverCtx ← Context.Server.mk + { cert := .text (testCertPEM.push '\x00'), key := .text testKeyPEM } + +-- `verifyPeer := false` succeeds without parsing the CA material, even for a real bundle. +def testMkFromPEMNoVerify : IO Unit := do + let _clientCtx ← Context.Client.mk { ca := some (.text testBundlePEM), verifyPeer := false } + +-- A bundle of several distinct certificates is loaded in full: every certificate in the PEM becomes +-- a trust anchor, not just the first one. Repeated certificates are skipped instead of failing, so a +-- bundle that overlaps the system trust anchors (or repeats itself) still yields a usable context. +def testMkFromPEMAcceptsBundle : IO Unit := do + let _clientCtx ← Context.Client.mk { ca := some (.text testBundlePEM) } + let _clientCtx2 ← Context.Client.mk { ca := some (.text (testBundlePEM ++ testCertPEM)) } + +-- Unlike `PEM.file`, `PEM.text` hands OpenSSL an explicit length rather than a C string, +-- so a NUL byte is data (here: trailing junk after a complete certificate) and not an error. +def testMkFromPEMAcceptsNulBytes : IO Unit := do + let _clientCtx ← Context.Client.mk { ca := some (.text (testCertPEM.push '\x00')) } + +def testMkNoVerifyIgnoresCorruptCAFile (f : Fixtures) : IO Unit := do + let _clientCtx ← Context.Client.mk { ca := some (.file f.corrupt), verifyPeer := false } + +def testMkFromPEMRejectsEmptyBlock : IO Unit := do + assertErrorMessage "PEM without certificates" (malformedPEMError caUnreadable) + (discard <| Context.Client.mk + { ca := some (.text "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n") }) + +-- Text with no PEM armour at all parses to an empty bundle rather than failing to parse, so it is +-- reported as "no certificates" rather than as unreadable. +def testMkRejectsMalformedCAFile (f : Fixtures) : IO Unit := do + assertErrorMessage "malformed CA file" (malformedFileError f.junk caNoCerts) + (discard <| Context.Client.mk { ca := some (.file f.junk) }) + +def testMkRejectsCorruptCAFile (f : Fixtures) : IO Unit := do + assertErrorMessage "one-bit-flipped CA file" (malformedFileError f.corrupt caUnreadable) + (discard <| Context.Client.mk { ca := some (.file f.corrupt) }) + +def testMkRejectsMissingCAFile : IO Unit := do + assertErrorMessage "missing CA file" + (missingFileError "/nonexistent/path/to/ca.pem") + (discard <| Context.Client.mk { ca := some (.file "/nonexistent/path/to/ca.pem") }) + +def testMkServerRejectsMissingFiles (f : Fixtures) : IO Unit := do + assertErrorMessage "missing server cert" + (missingFileError "/nonexistent/cert.pem") + (discard <| Context.Server.mk { cert := .file "/nonexistent/cert.pem", key := .file f.key }) + + assertErrorMessage "missing server key" + (missingFileError "/nonexistent/key.pem") + (discard <| Context.Server.mk { cert := .file f.cert, key := .file "/nonexistent/key.pem" }) + +def testMkServerRejectsMalformedKey (f : Fixtures) : IO Unit := do + assertErrorMessage "malformed server key" + (malformedFileError f.junk "could not read an unencrypted PEM private key") + (discard <| Context.Server.mk { cert := .file f.cert, key := .file f.junk }) + +def testMkServerRejectsCertAsKey (f : Fixtures) : IO Unit := do + assertErrorMessage "certificate used as server key" + (malformedFileError f.cert "could not read an unencrypted PEM private key") + (discard <| Context.Server.mk { cert := .file f.cert, key := .file f.cert }) + +def testMkServerRejectsMalformedCert (f : Fixtures) : IO Unit := do + assertErrorMessage "malformed server cert" + (malformedFileError f.junk "could not read a PEM certificate chain") + (discard <| Context.Server.mk { cert := .file f.junk, key := .file f.key }) + +def testMkServerRejectsCorruptCert (f : Fixtures) : IO Unit := do + assertErrorMessage "one-bit-flipped server cert" + (malformedFileError f.corrupt "could not read a PEM certificate chain") + (discard <| Context.Server.mk { cert := .file f.corrupt, key := .file f.key }) + +def testMkServerRejectsSwappedFiles (f : Fixtures) : IO Unit := do + assertErrorMessage "swapped server cert/key" + (malformedFileError f.key "could not read a PEM certificate chain") + (discard <| Context.Server.mk { cert := .file f.key, key := .file f.cert }) + +def testMkServerRejectsMismatchedKey (f : Fixtures) : IO Unit := do + assertErrorMessage "server key from a different pair" + (malformedFileError f.unrelatedKey "the private key does not match the certificate") + (discard <| Context.Server.mk { cert := .file f.cert, key := .file f.unrelatedKey }) + +-- A key of a different algorithm than the certificate lands in an unused slot of the context, so +-- `SSL_CTX_use_PrivateKey` accepts it without ever comparing the two; only the separate +-- `SSL_CTX_check_private_key` rejects it. +def testMkServerRejectsCrossAlgorithmKey (f : Fixtures) : IO Unit := do + assertErrorMessage "EC server key against an RSA certificate" + (malformedFileError f.ecKey "the private key does not match the certificate") + (discard <| Context.Server.mk { cert := .file f.cert, key := .file f.ecKey }) + +/-! +Encrypted PEM material must be rejected outright. A passphrase callback reporting failure is what +prevents OpenSSL falling back to its own callback, which prompts on `/dev/tty` and blocks forever — +a hang that no amount of redirecting stdin escapes. The point of these tests is as much the absence +of output as the error itself. An encrypted *certificate* is decrypted in place while the bundle is +read, so it reaches the callback through a different path than a key does, in each constructor. +-/ + +def testRejectsEncryptedMaterial (f : Fixtures) : IO Unit := do + assertErrorMessage "passphrase-protected server key" + (malformedFileError f.encKey "could not read an unencrypted PEM private key") + (discard <| Context.Server.mk { cert := .file f.cert, key := .file f.encKey }) + + -- An empty passphrase still counts as encrypted. A password callback that reports a zero-length + -- passphrase instead of a failure decrypts this key and accepts it. + assertErrorMessage "server key encrypted under an empty passphrase" + (malformedFileError f.emptyPwKey "could not read an unencrypted PEM private key") + (discard <| Context.Server.mk { cert := .file f.cert, key := .file f.emptyPwKey }) + + assertErrorMessage "encrypted server certificate" + (malformedFileError f.encCert "could not read a PEM certificate chain") + (discard <| Context.Server.mk { cert := .file f.encCert, key := .file f.key }) + + assertErrorMessage "encrypted CA certificate file" + (malformedFileError f.encCert caUnreadable) + (discard <| Context.Client.mk { ca := some (.file f.encCert) }) + + assertErrorMessage "in-memory encrypted key" + (malformedPEMError "could not read an unencrypted PEM private key") + (discard <| Context.Server.mk { cert := .text testCertPEM, key := .text testEncryptedKeyPEM }) + +def testRejectsNulInPaths (f : Fixtures) : IO Unit := do + let certPath := "cert\x00.pem" + let keyPath := "key\x00.pem" + let caPath := "ca\x00.pem" + + assertErrorMessage "NUL byte in server cert path" (nulByteError certPath) + (discard <| Context.Server.mk { cert := .file certPath, key := .file f.key }) + + assertErrorMessage "NUL byte in server key path" (nulByteError keyPath) + (discard <| Context.Server.mk { cert := .file f.cert, key := .file keyPath }) + + assertErrorMessage "NUL byte in CA path" (nulByteError caPath) + (discard <| Context.Client.mk { ca := some (.file caPath) }) + + -- The CA path is checked before `verifyPeer`, so a NUL is rejected even when the file would never + -- have been opened. + assertErrorMessage "NUL byte in CA path without verification" (nulByteError caPath) + (discard <| Context.Client.mk { ca := some (.file caPath), verifyPeer := false }) + +/-! +A CA bundle is required to contain at least one certificate. Material holding only non-certificate +entries parses without complaint and would leave the trust store silently unchanged, so the count is +checked explicitly. A *traditional* RSA key is the case that matters among those entries: it yields +a parsed entry carrying no certificate, unlike the PKCS#8 form which is dropped before that point. +A CRL is the other one. +-/ + +def testMkRejectsCertlessCAFile (f : Fixtures) : IO Unit := do + assertErrorMessage "CA file holding only a private key" (malformedFileError f.key caNoCerts) + (discard <| Context.Client.mk { ca := some (.file f.key) }) + + -- A zero-byte file has no PEM armour to fail on, so it parses to an empty bundle too. + assertErrorMessage "zero-byte CA file" (malformedFileError f.empty caNoCerts) + (discard <| Context.Client.mk { ca := some (.file f.empty) }) + +def testMkFromPEMRejectsCertlessPEM : IO Unit := do + assertErrorMessage "traditional RSA key with no certificate" (malformedPEMError caNoCerts) + (discard <| Context.Client.mk { ca := some (.text testTraditionalKeyPEM) }) + + assertErrorMessage "CA string holding only a CRL" (malformedPEMError caNoCerts) + (discard <| Context.Client.mk { ca := some (.text testCRLPEM) }) + +-- Non-certificate entries alongside a certificate are skipped rather than rejected. +def testMkFromPEMSkipsNonCertificates : IO Unit := do + let _clientCtx ← Context.Client.mk { ca := some (.text (testTraditionalKeyPEM ++ testCertPEM)) } + let _clientCtx2 ← Context.Client.mk { ca := some (.text (testCertPEM ++ testTraditionalKeyPEM)) } + let _clientCtx3 ← Context.Client.mk { ca := some (.text (testCRLPEM ++ testCertPEM)) } + let _clientCtx4 ← Context.Client.mk { ca := some (.text (testCertPEM ++ testCRLPEM)) } + +/-! +`PEM.text` hands OpenSSL an explicit length rather than a C string, so a NUL does not truncate the +input. It is still junk to the PEM parser, which needs `-----BEGIN` to start a line, so where the +NUL sits decides between three outcomes. +-/ + +-- Terminated by a newline the NUL is skipped like any other junk line. +def testMkFromPEMReadsPastNul : IO Unit := do + let _clientCtx ← Context.Client.mk { ca := some (.text ("\x00\n" ++ testCertPEM)) } + +-- Sharing a line with the marker, the NUL hides it and that certificate is dropped without an error +-- of its own; only the empty bundle behind it is reported. +def testMkFromPEMDropsCertBehindNul : IO Unit := do + assertErrorMessage "certificate behind an unterminated NUL" (malformedPEMError caNoCerts) + (discard <| Context.Client.mk { ca := some (.text ("\x00" ++ testCertPEM)) }) + +-- Inside the body the NUL corrupts the block, which discards the whole bundle rather than just that +-- certificate. +def testMkFromPEMRejectsNulInsideCert : IO Unit := do + let split := 200 + assertErrorMessage "NUL inside a certificate body" (malformedPEMError caUnreadable) + (discard <| Context.Client.mk { ca := some (.text + ((testCertPEM.take split).toString ++ "\x00" ++ (testCertPEM.drop split).toString)) }) + +-- The whole chain is loaded, not just the leaf, so a corrupt certificate in a later position is +-- still rejected. This is the observable difference between `SSL_CTX_use_certificate_chain_file` +-- and `SSL_CTX_use_certificate_file`. +def testMkServerRejectsCorruptChainMember (f : Fixtures) : IO Unit := do + assertErrorMessage "corrupt intermediate in the server chain" + (malformedFileError f.chain "could not read a PEM certificate chain") + (discard <| Context.Server.mk { cert := .file f.chain, key := .file f.key }) + +-- Building a context parses certificates; it does not check their validity period. An expired +-- certificate is therefore accepted here and only rejected at handshake time. +def testAcceptsExpiredCert (f : Fixtures) : IO Unit := do + let _serverCtx ← Context.Server.mk { cert := .file f.expired, key := .file f.key } + let _clientCtx ← Context.Client.mk { ca := some (.text testExpiredCertPEM) } + +/-! +A certificate can be refused on policy grounds rather than because it could not be read: the TLS +security level turns away an RSA key that is too short. Reporting that as unparsable PEM sends the +reader after a problem their file does not have. The key is 512 bits so that every level a build may +default to rejects it — OpenSSL defaults to level 2 only since 3.2, and level 1 still admits 1024. + +The level is not ours to fix, though: a context inherits it from the ambient `openssl.cnf`, and a build +configured `DEFAULT@SECLEVEL=0` admits the certificate outright. The weak certificate is therefore paired +with an unrelated key, so the load fails either way and the two failures can be told apart. +-/ + +def testMkServerRejectsWeakCert (f : Fixtures) : IO Unit := do + assertErrorMessageOneOf "512-bit server certificate" + [ malformedFileError f.weak + "the certificate is rejected by the TLS security level (key too small or signature digest too weak)", + malformedFileError f.key "the private key does not match the certificate" ] + (discard <| Context.Server.mk { cert := .file f.weak, key := .file f.key }) + +-- The security level governs the certificate a server presents, not the anchors a client trusts, so +-- the very file rejected above still loads as a CA. This is what pins the diagnosis to the security +-- level rather than to the certificate being malformed. +def testAcceptsWeakCertAsCA (f : Fixtures) : IO Unit := do + let _clientCtx ← Context.Client.mk { ca := some (.text testWeakCertPEM) } + let _clientCtx2 ← Context.Client.mk { ca := some (.file f.weak) } + +-- Only the *CA* material has a fallback to the platform anchors. The server has none, so an empty +-- path reaches the OS and fails there. +def testMkServerRejectsEmptyPaths (f : Fixtures) : IO Unit := do + -- `stat("")` is `ENOENT` on POSIX and `EINVAL` on the Windows CRT. + assertErrorMessageOneOf "empty server cert path" + [ missingFileError "", malformedFileError "" "could not read a PEM certificate chain" ] + (discard <| Context.Server.mk { cert := .file "", key := .file f.key }) + + assertErrorMessageOneOf "empty server key path" + [ missingFileError "", malformedFileError "" "could not read an unencrypted PEM private key" ] + (discard <| Context.Server.mk { cert := .file f.cert, key := .file "" }) + +/-! +The path is reported with the failure whenever the `IO.Error` constructor has room for it. These +also pin the errno itself, which is what would catch a platform decoding an OS error code through +the wrong table. +-/ + +-- Anything that is not a regular file is classified from its mode rather than by opening it, because +-- opening is not a reliable test: POSIX `fopen` succeeds on a directory and fails only at the first +-- read, and a FIFO blocks until a writer appears. The note is *appended* to the failure OpenSSL +-- actually reported rather than replacing it, because the file type need not be what went wrong — +-- OpenSSL reads a FIFO or a `/dev/fd` entry as happily as a file on disk, so a mismatched key reached +-- through one still has to say so. +def testRejectsDirectoryPaths (f : Fixtures) : IO Unit := do + let note := " (the path is not a regular file)" + + assertErrorMessage "directory as server cert" + (malformedFileError f.dir ("could not read a PEM certificate chain" ++ note)) + (discard <| Context.Server.mk { cert := .file f.dir, key := .file f.key }) + + assertErrorMessage "directory as server key" + (malformedFileError f.dir ("could not read an unencrypted PEM private key" ++ note)) + (discard <| Context.Server.mk { cert := .file f.cert, key := .file f.dir }) + + -- Which failure this is depends on the C library. On POSIX `BIO_new_file` opens the directory and + -- the read that follows yields nothing, so the empty bundle is what gets reported; the Windows CRT + -- cannot open a directory as a stream at all, so the BIO is null and the path is unreadable instead. + -- Either way the note has to be appended rather than substituted, or which of the two it was is lost. + assertErrorMessageOneOf "directory as CA file" + [ malformedFileError f.dir (caNoCerts ++ note), + malformedFileError f.dir (caUnreadable ++ note) ] + (discard <| Context.Client.mk { ca := some (.file f.dir) }) + +-- A character device is the readable non-regular file: OpenSSL opens `/dev/null` and reads it to +-- completion, so the diagnosis is about what the empty read produced and the file type is only a +-- footnote. +def testAppendsNoteToReadableNonRegularFile (f : Fixtures) : IO Unit := do + if System.Platform.isWindows then + return + + assertErrorMessage "character device as CA file" + (malformedFileError "/dev/null" (caNoCerts ++ " (the path is not a regular file)")) + (discard <| Context.Client.mk { ca := some (.file "/dev/null") }) + + assertErrorMessage "character device as server key" + (malformedFileError "/dev/null" + "could not read an unencrypted PEM private key (the path is not a regular file)") + (discard <| Context.Server.mk { cert := .file f.cert, key := .file "/dev/null" }) + +-- Skipped when the permission bits do not bite, which is the case for a privileged user. +def testMkRejectsUnreadableCAFile (f : Fixtures) : IO Unit := do + if (← (IO.FS.readFile f.unreadable).toBaseIO).isOk then + return + + assertErrorMessage "CA file with no read permission" + s!"permission denied (error code: 13)\n file: {f.unreadable}" + (discard <| Context.Client.mk { ca := some (.file f.unreadable) }) + +-- A path traversing a regular file is `ENOTDIR` on POSIX; the Windows CRT reports `ENOENT`. +def testMkRejectsNonDirectoryParent (f : Fixtures) : IO Unit := do + assertErrorMessageOneOf "CA path whose parent is a regular file" + [ s!"inappropriate type (error code: 20, not a directory)\n file: {f.nonDirParent}", + missingFileError f.nonDirParent ] + (discard <| Context.Client.mk { ca := some (.file f.nonDirParent) }) + +#eval do + let f ← mkFixtures + + testContextCreation f + testMkFromPEMEmptyFallsBack + testMkServerFromMemory f + testMkServerFromMemoryErrors + testMkServerFromMemoryAcceptsNul + testMkFromPEMNoVerify + testMkFromPEMAcceptsBundle + testMkFromPEMAcceptsNulBytes + +-- Encrypted PEM, in every constructor. A regression here does not fail loudly: it blocks on a +-- passphrase prompt, so keep these ahead of anything that would mask a hang. +#eval do + let f ← mkFixtures + testRejectsEncryptedMaterial f + +-- Pinning: `trustSystemRoots := false` narrows the store to the supplied CA. +#eval do + let f ← mkFixtures + + testPinnedToSuppliedCA f + testPinningRejectsEmptyCA + testPinningRejectsEmptyCAMaterial + testPinningIgnoredWithoutVerification + testPinningStillValidatesCA f + testPinningRejectsNulInCAFile + +-- A trust anchor must be one a chain can terminate at. +#eval do + let f ← mkFixtures + + testPinningRejectsIntermediateOnly f + testPinningToIntermediateWithPartialChain f + testPinningAcceptsRootWithIntermediate + testIntermediateAllowedBesideSystemRoots + testIntermediateIgnoredWithoutVerification + +-- CA material that cannot be used as a trust anchor. +#eval do + let f ← mkFixtures + + testMkRejectsMissingCAFile + testMkRejectsMalformedCAFile f + testMkRejectsCorruptCAFile f + testMkNoVerifyIgnoresCorruptCAFile f + testMkFromPEMRejectsEmptyBlock + testMkRejectsCertlessCAFile f + testMkFromPEMRejectsCertlessPEM + testMkFromPEMSkipsNonCertificates + +-- Server credentials that do not load. +#eval do + let f ← mkFixtures + + testMkServerRejectsMissingFiles f + testMkServerRejectsMalformedCert f + testMkServerRejectsMalformedKey f + testMkServerRejectsCorruptCert f + testMkServerRejectsCertAsKey f + testMkServerRejectsSwappedFiles f + testMkServerRejectsMismatchedKey f + testMkServerRejectsCrossAlgorithmKey f + testMkServerRejectsCorruptChainMember f + testRejectsNulInPaths f + +-- NUL is data, not a terminator, but it is not invisible either. +#eval do + testMkFromPEMReadsPastNul + testMkFromPEMDropsCertBehindNul + testMkFromPEMRejectsNulInsideCert + +-- Accepted here, rejected later: the clock and the security level. +#eval do + let f ← mkFixtures + + testAcceptsExpiredCert f + testMkServerRejectsWeakCert f + testAcceptsWeakCertAsCA f + +-- OS-level failures keep the path and the real errno. +#eval do + let f ← mkFixtures + + testMkRejectsUnreadableCAFile f + testMkRejectsNonDirectoryParent f + testMkServerRejectsEmptyPaths f + testRejectsDirectoryPaths f + testAppendsNoteToReadableNonRegularFile f diff --git a/tests/elab/openssl.lean b/tests/elab/openssl.lean index 312656942adb..84d4042587cc 100644 --- a/tests/elab/openssl.lean +++ b/tests/elab/openssl.lean @@ -1,6 +1,10 @@ import Lean.Runtime --- Non-emscripten build: expect the major version of OpenSSL (3) -/-- info: 3 -/ +/-! +Checks that Lean reports the version of the OpenSSL it is linked against. `find_package(OpenSSL 3)` +sets a floor rather than pinning a major version, so this asserts the floor holds. +-/ + +/-- info: true -/ #guard_msgs in -#eval if !System.Platform.isEmscripten then Lean.openSSLVersion >>> 28 else 3 +#eval System.Platform.isEmscripten || Lean.openSSLVersion >>> 28 >= 3