From 52c9b546a7c9b150e3fad0489e0879079b004a2e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 21:11:02 +0000 Subject: [PATCH] feat(tls): static OCSP stapling Adds opt-in OCSP stapling for TLS listeners. Setting "ocsp_staple": true on a listener's "tls" object makes the router fetch a DER-encoded OCSP response from .ocsp alongside the certificate in the cert store and serve it via the TLS status_request extension. Operator refreshes the response file out-of-band (e.g. cron + openssl ocsp). Implementation: - Build probe NXT_HAVE_OPENSSL_OCSP (auto/ssltls). - nxt_tls_bundle_conf_t carries the cached DER + open fd; nxt_tls_init_t carries the per-listener boolean. - New cert_ocsp_get IPC handler opens /.ocsp read-only and returns the fd to the router; missing files are not errors (handshake just runs without stapling). - nxt_openssl.c parses the DER (d2i_OCSP_RESPONSE), checks responseStatus == successful, and validates thisUpdate/nextUpdate via OCSP_check_validity so an expired or future-dated response fails the config apply rather than silently breaking handshakes. Cached bytes are served per handshake via SSL_CTX_set_tlsext_status_cb (with the OPENSSL_malloc copy the API requires); response size capped at 64 KiB. - Router flow chains cert fetch -> ocsp fetch -> server_init. Phase 1 of the D4 TLS modernization roadmap entry; auto-fetch from AIA responder URL is deliberately deferred. --- CHANGES | 6 + auto/ssltls | 19 +++ src/nxt_cert.c | 136 +++++++++++++++ src/nxt_cert.h | 4 + src/nxt_conf_validation.c | 9 + src/nxt_main_process.c | 1 + src/nxt_openssl.c | 213 ++++++++++++++++++++++++ src/nxt_port.h | 3 + src/nxt_router.c | 168 ++++++++++++++++++- src/nxt_tls.h | 3 + test/test_tls_ocsp.py | 342 ++++++++++++++++++++++++++++++++++++++ 11 files changed, 895 insertions(+), 9 deletions(-) create mode 100644 test/test_tls_ocsp.py diff --git a/CHANGES b/CHANGES index 8ae835854..45583380c 100644 --- a/CHANGES +++ b/CHANGES @@ -1,6 +1,12 @@ Changes with FreeUnit 1.35.4 xx xxx 2026 + *) Feature: TLS OCSP stapling. Setting "ocsp_staple": true on a listener's + "tls" object enables stapling; the OCSP response (DER) is loaded from + .ocsp alongside the certificate in the certificate store and + served in the TLS handshake. Operator refreshes the response file + out-of-band (e.g. via cron + openssl ocsp). + *) Bugfix: fix router process CPU spin and connection hang under port scanning load; CLOSE-WAIT sockets are now cleaned up properly on client FIN, idle connection queue iteration fixed, systemd file diff --git a/auto/ssltls b/auto/ssltls index b275e6140..2cfa42d8d 100644 --- a/auto/ssltls +++ b/auto/ssltls @@ -84,6 +84,25 @@ if [ $NXT_OPENSSL = YES ]; then #endif }" . auto/feature + + + nxt_feature="OpenSSL OCSP stapling" + nxt_feature_name=NXT_HAVE_OPENSSL_OCSP + nxt_feature_run= + nxt_feature_incs= + nxt_feature_libs="$NXT_OPENSSL_LIBS" + nxt_feature_test="#include +#include + + int main(void) { + SSL_CTX *ctx = NULL; + SSL *ssl = NULL; + SSL_CTX_set_tlsext_status_cb(ctx, NULL); + SSL_CTX_set_tlsext_status_arg(ctx, NULL); + SSL_set_tlsext_status_ocsp_resp(ssl, NULL, 0); + return 0; + }" + . auto/feature fi diff --git a/src/nxt_cert.c b/src/nxt_cert.c index 600a6c011..3620c3ba1 100644 --- a/src/nxt_cert.c +++ b/src/nxt_cert.c @@ -1229,6 +1229,142 @@ nxt_cert_store_get_handler(nxt_task_t *task, nxt_port_recv_msg_t *msg) } +void +nxt_cert_store_get_ocsp(nxt_task_t *task, nxt_str_t *name, nxt_mp_t *mp, + nxt_port_rpc_handler_t handler, void *ctx) +{ + uint32_t stream; + nxt_int_t ret; + nxt_buf_t *b; + nxt_port_t *main_port, *recv_port; + nxt_runtime_t *rt; + + b = nxt_buf_mem_alloc(mp, name->length + 1, 0); + if (nxt_slow_path(b == NULL)) { + goto fail; + } + + b->completion_handler = nxt_cert_buf_completion; + + nxt_buf_cpystr(b, name); + *b->mem.free++ = '\0'; + + rt = task->thread->runtime; + main_port = rt->port_by_type[NXT_PROCESS_MAIN]; + recv_port = rt->port_by_type[rt->type]; + + stream = nxt_port_rpc_register_handler(task, recv_port, handler, handler, + -1, ctx); + if (nxt_slow_path(stream == 0)) { + goto fail; + } + + ret = nxt_port_socket_write(task, main_port, NXT_PORT_MSG_CERT_OCSP_GET, -1, + stream, recv_port->id, b); + + if (nxt_slow_path(ret != NXT_OK)) { + nxt_port_rpc_cancel(task, recv_port, stream); + goto fail; + } + + /* + * Retain only after the buffer has been handed off to the port + * machinery; the failure paths above otherwise leave the pool with a + * refcount that the completion handler can never release. + */ + nxt_mp_retain(mp); + + return; + +fail: + + handler(task, NULL, ctx); +} + + +void +nxt_cert_store_get_ocsp_handler(nxt_task_t *task, nxt_port_recv_msg_t *msg) +{ + u_char *p; + nxt_int_t ret; + nxt_str_t name; + nxt_file_t file; + nxt_port_t *port; + nxt_runtime_t *rt; + nxt_port_msg_type_t type; + static const char ocsp_suffix[] = ".ocsp"; + + port = nxt_runtime_port_find(task->thread->runtime, msg->port_msg.pid, + msg->port_msg.reply_port); + + if (nxt_slow_path(port == NULL)) { + nxt_alert(task, "process port not found (pid %PI, reply_port %d)", + msg->port_msg.pid, msg->port_msg.reply_port); + return; + } + + if (nxt_slow_path(port->type != NXT_PROCESS_ROUTER)) { + nxt_alert(task, "process %PI cannot fetch OCSP responses", + msg->port_msg.pid); + return; + } + + nxt_memzero(&file, sizeof(nxt_file_t)); + + file.fd = -1; + /* + * RPC_READY_LAST with fd == -1 signals "no OCSP file present" + * to the router; missing siblings are not errors. + */ + type = NXT_PORT_MSG_RPC_READY_LAST; + + rt = task->thread->runtime; + + if (nxt_slow_path(rt->certs.start == NULL)) { + goto reply; + } + + name.start = msg->buf->mem.pos; + name.length = nxt_strlen(name.start); + + file.name = nxt_malloc(rt->certs.length + name.length + + sizeof(ocsp_suffix)); + + if (nxt_slow_path(file.name == NULL)) { + goto reply; + } + + p = nxt_cpymem(file.name, rt->certs.start, rt->certs.length); + p = nxt_cpymem(p, name.start, name.length); + nxt_memcpy(p, ocsp_suffix, sizeof(ocsp_suffix)); + + ret = nxt_file_open(task, &file, NXT_FILE_RDONLY, NXT_FILE_OPEN, + NXT_FILE_OWNER_ACCESS); + + nxt_free(file.name); + + if (ret == NXT_OK) { + type = NXT_PORT_MSG_RPC_READY_LAST | NXT_PORT_MSG_CLOSE_FD; + } + +reply: + + if (nxt_port_socket_write(task, port, type, file.fd, + msg->port_msg.stream, 0, NULL) + != NXT_OK + && file.fd != -1) + { + /* + * On send failure (e.g. malloc failure inside the port machinery) + * the port layer never takes ownership of the fd, so close it + * here to avoid leaking an open file descriptor in the privileged + * main process. + */ + nxt_fd_close(file.fd); + } +} + + void nxt_cert_store_delete(nxt_task_t *task, nxt_str_t *name, nxt_mp_t *mp) { diff --git a/src/nxt_cert.h b/src/nxt_cert.h index dbaddcf93..183fac10e 100644 --- a/src/nxt_cert.h +++ b/src/nxt_cert.h @@ -24,9 +24,13 @@ void nxt_cert_store_release(nxt_array_t *certs); void nxt_cert_store_get(nxt_task_t *task, nxt_str_t *name, nxt_mp_t *mp, nxt_port_rpc_handler_t handler, void *ctx); +void nxt_cert_store_get_ocsp(nxt_task_t *task, nxt_str_t *name, nxt_mp_t *mp, + nxt_port_rpc_handler_t handler, void *ctx); void nxt_cert_store_delete(nxt_task_t *task, nxt_str_t *name, nxt_mp_t *mp); void nxt_cert_store_get_handler(nxt_task_t *task, nxt_port_recv_msg_t *msg); +void nxt_cert_store_get_ocsp_handler(nxt_task_t *task, + nxt_port_recv_msg_t *msg); void nxt_cert_store_delete_handler(nxt_task_t *task, nxt_port_recv_msg_t *msg); #endif /* _NXT_CERT_INCLUDED_ */ diff --git a/src/nxt_conf_validation.c b/src/nxt_conf_validation.c index 8324a4419..9d8b2b768 100644 --- a/src/nxt_conf_validation.c +++ b/src/nxt_conf_validation.c @@ -612,6 +612,15 @@ static nxt_conf_vldt_object_t nxt_conf_vldt_tls_members[] = { .type = NXT_CONF_VLDT_OBJECT, .validator = nxt_conf_vldt_object, .u.members = nxt_conf_vldt_session_members, + }, { + .name = nxt_string("ocsp_staple"), + .type = NXT_CONF_VLDT_BOOLEAN, +#if (NXT_HAVE_OPENSSL_OCSP) + .validator = NULL, +#else + .validator = nxt_conf_vldt_unsupported, + .u.string = "ocsp_staple", +#endif }, NXT_CONF_VLDT_END diff --git a/src/nxt_main_process.c b/src/nxt_main_process.c index 25798ea0b..7842a26fc 100644 --- a/src/nxt_main_process.c +++ b/src/nxt_main_process.c @@ -679,6 +679,7 @@ static nxt_port_handlers_t nxt_main_process_port_handlers = { .conf_store = nxt_main_port_conf_store_handler, #if (NXT_TLS) .cert_get = nxt_cert_store_get_handler, + .cert_ocsp_get = nxt_cert_store_get_ocsp_handler, .cert_delete = nxt_cert_store_delete_handler, #endif #if (NXT_HAVE_NJS) diff --git a/src/nxt_openssl.c b/src/nxt_openssl.c index 265542aae..0fbb6fd30 100644 --- a/src/nxt_openssl.c +++ b/src/nxt_openssl.c @@ -16,6 +16,9 @@ #include #include #include +#if (NXT_HAVE_OPENSSL_OCSP) +#include +#endif typedef struct { @@ -65,6 +68,11 @@ static nxt_int_t nxt_openssl_server_init(nxt_task_t *task, nxt_mp_t *mp, nxt_tls_init_t *tls_init, nxt_bool_t last); static nxt_int_t nxt_openssl_chain_file(nxt_task_t *task, SSL_CTX *ctx, nxt_tls_conf_t *conf, nxt_mp_t *mp, nxt_bool_t single); +#if (NXT_HAVE_OPENSSL_OCSP) +static nxt_int_t nxt_openssl_ocsp_load(nxt_task_t *task, nxt_mp_t *mp, + nxt_tls_bundle_conf_t *bundle); +static int nxt_openssl_ocsp_status_cb(SSL *s, void *arg); +#endif #if (NXT_HAVE_OPENSSL_CONF_CMD) static nxt_int_t nxt_ssl_conf_commands(nxt_task_t *task, SSL_CTX *ctx, nxt_conf_value_t *value, nxt_mp_t *mp); @@ -350,6 +358,19 @@ nxt_openssl_server_init(nxt_task_t *task, nxt_mp_t *mp, { goto fail; } + +#if (NXT_HAVE_OPENSSL_OCSP) + if (tls_init->ocsp_staple) { + if (nxt_openssl_ocsp_load(task, mp, bundle) != NXT_OK) { + goto fail; + } + + if (bundle->ocsp_staple.length != 0) { + SSL_CTX_set_tlsext_status_cb(ctx, nxt_openssl_ocsp_status_cb); + SSL_CTX_set_tlsext_status_arg(ctx, bundle); + } + } +#endif /* key = conf->certificate_key; @@ -468,6 +489,8 @@ nxt_openssl_chain_file(nxt_task_t *task, SSL_CTX *ctx, nxt_tls_conf_t *conf, bundle = conf->bundle; BIO_set_fd(bio, bundle->chain_file, BIO_CLOSE); + /* BIO owns the fd now (BIO_CLOSE) and BIO_free below closes it. */ + bundle->chain_file = -1; cert = PEM_read_bio_X509_AUX(bio, NULL, NULL, NULL); if (cert == NULL) { @@ -541,6 +564,196 @@ nxt_openssl_chain_file(nxt_task_t *task, SSL_CTX *ctx, nxt_tls_conf_t *conf, } +#if (NXT_HAVE_OPENSSL_OCSP) + +#define NXT_OPENSSL_OCSP_MAX_SIZE (64 * 1024) + + +static nxt_int_t +nxt_openssl_ocsp_validate(nxt_task_t *task, nxt_tls_bundle_conf_t *bundle, + const u_char *der, size_t len) +{ + int status; + OCSP_RESPONSE *resp; + OCSP_BASICRESP *basic; + OCSP_SINGLERESP *single; + ASN1_GENERALIZEDTIME *this_upd, *next_upd; + const u_char *p; + + p = der; + resp = d2i_OCSP_RESPONSE(NULL, &p, len); + if (resp == NULL) { + nxt_alert(task, "ocsp_staple: \"%V\" is not a valid OCSP response " + "(d2i_OCSP_RESPONSE failed)", &bundle->name); + return NXT_ERROR; + } + + status = OCSP_response_status(resp); + if (status != OCSP_RESPONSE_STATUS_SUCCESSFUL) { + nxt_alert(task, "ocsp_staple: \"%V\" has non-successful status %d " + "(%s)", &bundle->name, status, + OCSP_response_status_str(status)); + OCSP_RESPONSE_free(resp); + return NXT_ERROR; + } + + basic = OCSP_response_get1_basic(resp); + if (basic == NULL) { + nxt_alert(task, "ocsp_staple: \"%V\" missing basic response", + &bundle->name); + OCSP_RESPONSE_free(resp); + return NXT_ERROR; + } + + if (OCSP_resp_count(basic) < 1) { + nxt_alert(task, "ocsp_staple: \"%V\" carries no SingleResponses", + &bundle->name); + OCSP_BASICRESP_free(basic); + OCSP_RESPONSE_free(resp); + return NXT_ERROR; + } + + single = OCSP_resp_get0(basic, 0); + this_upd = NULL; + next_upd = NULL; + + if (OCSP_single_get0_status(single, NULL, NULL, &this_upd, &next_upd) + == -1) + { + nxt_alert(task, "ocsp_staple: \"%V\" SingleResponse has no status", + &bundle->name); + OCSP_BASICRESP_free(basic); + OCSP_RESPONSE_free(resp); + return NXT_ERROR; + } + + /* + * OCSP_check_validity() applies the freshness/skew rules: a non-NULL + * nextUpdate must lie in the future (with default 5 min skew). A + * missing nextUpdate is allowed by the API; we still log a warning. + */ + if (next_upd == NULL) { + nxt_log(task, NXT_LOG_WARN, + "ocsp_staple: \"%V\" has no nextUpdate; clients may reject " + "or refetch on every connection", &bundle->name); + + } else if (OCSP_check_validity(this_upd, next_upd, 300, -1) != 1) { + nxt_alert(task, "ocsp_staple: \"%V\" is expired or not yet valid; " + "refresh the .ocsp file", &bundle->name); + ERR_clear_error(); + OCSP_BASICRESP_free(basic); + OCSP_RESPONSE_free(resp); + return NXT_ERROR; + } + + OCSP_BASICRESP_free(basic); + OCSP_RESPONSE_free(resp); + + return NXT_OK; +} + + +static nxt_int_t +nxt_openssl_ocsp_load(nxt_task_t *task, nxt_mp_t *mp, + nxt_tls_bundle_conf_t *bundle) +{ + u_char *buf; + size_t total; + ssize_t n; + nxt_file_t file; + nxt_file_info_t fi; + + if (bundle->ocsp_file == -1) { + /* No .ocsp sibling for this certificate. */ + bundle->ocsp_staple.length = 0; + bundle->ocsp_staple.start = NULL; + return NXT_OK; + } + + nxt_memzero(&file, sizeof(nxt_file_t)); + file.fd = bundle->ocsp_file; + + if (nxt_file_info(&file, &fi) != NXT_OK) { + nxt_alert(task, "ocsp_staple: stat failed for \"%V\"", &bundle->name); + goto fail; + } + + total = nxt_file_size(&fi); + + if (total == 0 || total > NXT_OPENSSL_OCSP_MAX_SIZE) { + nxt_alert(task, "ocsp_staple: \"%V\" has invalid size %uz " + "(must be 1..%d bytes)", + &bundle->name, total, NXT_OPENSSL_OCSP_MAX_SIZE); + goto fail; + } + + buf = nxt_mp_nget(mp, total); + if (buf == NULL) { + goto fail; + } + + n = nxt_file_read(&file, buf, total, 0); + if (n < (ssize_t) total) { + nxt_alert(task, "ocsp_staple: short read on \"%V\"", &bundle->name); + goto fail; + } + + if (nxt_openssl_ocsp_validate(task, bundle, buf, total) != NXT_OK) { + goto fail; + } + + bundle->ocsp_staple.start = buf; + bundle->ocsp_staple.length = total; + + nxt_file_close(task, &file); + bundle->ocsp_file = -1; + + return NXT_OK; + +fail: + + nxt_file_close(task, &file); + bundle->ocsp_file = -1; + return NXT_ERROR; +} + + +static int +nxt_openssl_ocsp_status_cb(SSL *s, void *arg) +{ + u_char *copy; + nxt_tls_bundle_conf_t *bundle; + + bundle = arg; + + if (bundle->ocsp_staple.length == 0) { + return SSL_TLSEXT_ERR_NOACK; + } + + /* + * SSL_set_tlsext_status_ocsp_resp() takes ownership of the buffer and + * frees it with OPENSSL_free(); allocate a fresh copy per handshake. + */ + copy = OPENSSL_malloc(bundle->ocsp_staple.length); + if (copy == NULL) { + return SSL_TLSEXT_ERR_NOACK; + } + + nxt_memcpy(copy, bundle->ocsp_staple.start, bundle->ocsp_staple.length); + + if (SSL_set_tlsext_status_ocsp_resp(s, copy, bundle->ocsp_staple.length) + != 1) + { + OPENSSL_free(copy); + return SSL_TLSEXT_ERR_NOACK; + } + + return SSL_TLSEXT_ERR_OK; +} + +#endif + + #if (NXT_HAVE_OPENSSL_CONF_CMD) static nxt_int_t diff --git a/src/nxt_port.h b/src/nxt_port.h index 772fb41ae..79dfb84cf 100644 --- a/src/nxt_port.h +++ b/src/nxt_port.h @@ -20,6 +20,7 @@ struct nxt_port_handlers_s { nxt_port_handler_t modules; nxt_port_handler_t conf_store; nxt_port_handler_t cert_get; + nxt_port_handler_t cert_ocsp_get; nxt_port_handler_t cert_delete; nxt_port_handler_t script_get; nxt_port_handler_t script_delete; @@ -88,6 +89,7 @@ typedef enum { _NXT_PORT_MSG_MODULES = nxt_port_handler_idx(modules), _NXT_PORT_MSG_CONF_STORE = nxt_port_handler_idx(conf_store), _NXT_PORT_MSG_CERT_GET = nxt_port_handler_idx(cert_get), + _NXT_PORT_MSG_CERT_OCSP_GET = nxt_port_handler_idx(cert_ocsp_get), _NXT_PORT_MSG_CERT_DELETE = nxt_port_handler_idx(cert_delete), _NXT_PORT_MSG_SCRIPT_GET = nxt_port_handler_idx(script_get), _NXT_PORT_MSG_SCRIPT_DELETE = nxt_port_handler_idx(script_delete), @@ -132,6 +134,7 @@ typedef enum { NXT_PORT_MSG_MODULES = nxt_msg_last(_NXT_PORT_MSG_MODULES), NXT_PORT_MSG_CONF_STORE = nxt_msg_last(_NXT_PORT_MSG_CONF_STORE), NXT_PORT_MSG_CERT_GET = nxt_msg_last(_NXT_PORT_MSG_CERT_GET), + NXT_PORT_MSG_CERT_OCSP_GET = nxt_msg_last(_NXT_PORT_MSG_CERT_OCSP_GET), NXT_PORT_MSG_CERT_DELETE = nxt_msg_last(_NXT_PORT_MSG_CERT_DELETE), NXT_PORT_MSG_SCRIPT_GET = nxt_msg_last(_NXT_PORT_MSG_SCRIPT_GET), NXT_PORT_MSG_SCRIPT_DELETE = nxt_msg_last(_NXT_PORT_MSG_SCRIPT_DELETE), diff --git a/src/nxt_router.c b/src/nxt_router.c index c994f3707..04cbcdb91 100644 --- a/src/nxt_router.c +++ b/src/nxt_router.c @@ -154,6 +154,8 @@ static void nxt_router_listen_socket_ready(nxt_task_t *task, static void nxt_router_listen_socket_error(nxt_task_t *task, nxt_port_recv_msg_t *msg, void *data); #if (NXT_TLS) +static void nxt_router_tls_ocsp_handler(nxt_task_t *task, + nxt_port_recv_msg_t *msg, void *data); static void nxt_router_tls_rpc_handler(nxt_task_t *task, nxt_port_recv_msg_t *msg, void *data); static nxt_int_t nxt_router_conf_tls_insert(nxt_router_temp_conf_t *tmcf, @@ -1674,6 +1676,8 @@ nxt_router_conf_create(nxt_task_t *task, nxt_router_temp_conf_t *tmcf, static const nxt_str_t conf_timeout_path = nxt_string("/tls/session/timeout"); static const nxt_str_t conf_tickets = nxt_string("/tls/session/tickets"); + static const nxt_str_t conf_ocsp_staple = + nxt_string("/tls/ocsp_staple"); #endif #if (NXT_HAVE_NJS) static const nxt_str_t js_module_path = nxt_string("/settings/js_module"); @@ -2127,6 +2131,13 @@ nxt_router_conf_create(nxt_task_t *task, nxt_router_temp_conf_t *tmcf, tls_init->tickets_conf = nxt_conf_get_path(listener, &conf_tickets); + tls_init->ocsp_staple = 0; + + value = nxt_conf_get_path(listener, &conf_ocsp_staple); + if (value != NULL) { + tls_init->ocsp_staple = nxt_conf_get_boolean(value); + } + n = nxt_conf_array_elements_count_or_1(certificate); for (i = 0; i < n; i++) { @@ -3031,26 +3042,55 @@ nxt_router_listen_socket_error(nxt_task_t *task, nxt_port_recv_msg_t *msg, #if (NXT_TLS) +typedef struct { + nxt_router_tlssock_t *tls; + nxt_tls_bundle_conf_t *bundle; +} nxt_router_tls_ocsp_ctx_t; + + static void nxt_router_tls_rpc_handler(nxt_task_t *task, nxt_port_recv_msg_t *msg, void *data) { - nxt_mp_t *mp; - nxt_int_t ret; - nxt_tls_conf_t *tlscf; - nxt_router_tlssock_t *tls; - nxt_tls_bundle_conf_t *bundle; - nxt_router_temp_conf_t *tmcf; + nxt_mp_t *mp; + nxt_fd_t cert_fd; + nxt_tls_conf_t *tlscf; + nxt_router_tlssock_t *tls; + nxt_tls_bundle_conf_t *bundle; + nxt_router_temp_conf_t *tmcf; + nxt_router_tls_ocsp_ctx_t *ctx; nxt_debug(task, "tls rpc handler"); tls = data; tmcf = tls->temp_conf; + cert_fd = -1; + + if (msg == NULL) { + goto fail; + } + + if (msg->port_msg.type == _NXT_PORT_MSG_RPC_ERROR) { + /* + * RPC_ERROR carries no fd by contract, but defensively close any + * fd that did slip through to avoid an FD leak. + */ + if (msg->fd[0] != -1) { + nxt_fd_close(msg->fd[0]); + msg->fd[0] = -1; + } - if (msg == NULL || msg->port_msg.type == _NXT_PORT_MSG_RPC_ERROR) { goto fail; } + /* + * Take ownership of the cert fd up front so every early failure path + * below can release it via the fail label. Once it is parked on the + * bundle, the bundle's fail-path closer keeps it covered until + * nxt_openssl_chain_file() consumes it (BIO_CLOSE). + */ + cert_fd = msg->fd[0]; + mp = tmcf->router_conf->mem_pool; if (tls->socket_conf->tls == NULL) { @@ -3068,16 +3108,116 @@ nxt_router_tls_rpc_handler(nxt_task_t *task, nxt_port_recv_msg_t *msg, tls->tls_init->conf = tlscf; - bundle = nxt_mp_get(mp, sizeof(nxt_tls_bundle_conf_t)); + bundle = nxt_mp_zget(mp, sizeof(nxt_tls_bundle_conf_t)); if (nxt_slow_path(bundle == NULL)) { goto fail; } + /* + * nxt_mp_zget() leaves fds at 0 (== stdin); -1 is the conventional + * invalid sentinel. Set both before any path that might cleanup. + */ + bundle->chain_file = -1; + bundle->ocsp_file = -1; + if (nxt_slow_path(nxt_str_dup(mp, &bundle->name, &tls->name) == NULL)) { goto fail; } - bundle->chain_file = msg->fd[0]; + bundle->chain_file = cert_fd; + cert_fd = -1; + + /* + * Bundle is intentionally NOT inserted into tlscf->bundle yet. + * nxt_openssl_server_init() reads conf->bundle as the bundle to + * initialise; with multiple certificates per listener and async OCSP + * fetches, inserting at the head here would race other listeners' + * cert RPCs and cause the OCSP handler to attach a response to the + * wrong bundle. The OCSP handler links the bundle just before + * calling server_init() so the head invariant holds. + */ + + ctx = nxt_mp_get(mp, sizeof(nxt_router_tls_ocsp_ctx_t)); + if (nxt_slow_path(ctx == NULL)) { + if (bundle->chain_file != -1) { + nxt_fd_close(bundle->chain_file); + bundle->chain_file = -1; + } + goto fail; + } + + ctx->tls = tls; + ctx->bundle = bundle; + +#if (NXT_HAVE_OPENSSL_OCSP) + if (tls->tls_init->ocsp_staple) { + nxt_cert_store_get_ocsp(task, &tls->name, mp, + nxt_router_tls_ocsp_handler, ctx); + return; + } +#endif + + nxt_router_tls_ocsp_handler(task, NULL, ctx); + return; + +fail: + + if (cert_fd != -1) { + nxt_fd_close(cert_fd); + } + + nxt_router_conf_error(task, tmcf); +} + + +static void +nxt_router_tls_ocsp_handler(nxt_task_t *task, nxt_port_recv_msg_t *msg, + void *data) +{ + nxt_mp_t *mp; + nxt_int_t ret; + nxt_tls_conf_t *tlscf; + nxt_router_tlssock_t *tls; + nxt_tls_bundle_conf_t *bundle; + nxt_router_temp_conf_t *tmcf; + nxt_router_tls_ocsp_ctx_t *ctx; + + ctx = data; + tls = ctx->tls; + bundle = ctx->bundle; + tmcf = tls->temp_conf; + mp = tmcf->router_conf->mem_pool; + tlscf = tls->tls_init->conf; + +#if (NXT_HAVE_OPENSSL_OCSP) + if (msg != NULL) { + if (msg->port_msg.type == _NXT_PORT_MSG_RPC_ERROR) { + /* + * RPC_ERROR carries no fd by contract, but defensively close + * any fd that did slip through to avoid an FD leak. + */ + if (msg->fd[0] != -1) { + nxt_fd_close(msg->fd[0]); + msg->fd[0] = -1; + } + + goto fail; + } + + /* fd may be -1 when the .ocsp sibling is absent. */ + bundle->ocsp_file = msg->fd[0]; + } +#else + (void) msg; +#endif + + /* + * Link the bundle as the head right before server_init() so that + * conf->bundle reads back the bundle this RPC was initiated for. + * This is also what nxt_openssl_chain_file() and + * nxt_openssl_cert_get_names() rely on when registering the + * SNI hash entries for the multi-certificate case. + */ bundle->next = tlscf->bundle; tlscf->bundle = bundle; @@ -3093,6 +3233,16 @@ nxt_router_tls_rpc_handler(nxt_task_t *task, nxt_port_recv_msg_t *msg, fail: + if (bundle->ocsp_file != -1) { + nxt_fd_close(bundle->ocsp_file); + bundle->ocsp_file = -1; + } + + if (bundle->chain_file != -1) { + nxt_fd_close(bundle->chain_file); + bundle->chain_file = -1; + } + nxt_router_conf_error(task, tmcf); } diff --git a/src/nxt_tls.h b/src/nxt_tls.h index 0667ade3c..3f5abe380 100644 --- a/src/nxt_tls.h +++ b/src/nxt_tls.h @@ -55,6 +55,8 @@ struct nxt_tls_bundle_conf_s { void *ctx; nxt_fd_t chain_file; + nxt_fd_t ocsp_file; + nxt_str_t ocsp_staple; nxt_str_t name; nxt_tls_bundle_conf_t *next; @@ -87,6 +89,7 @@ struct nxt_tls_init_s { nxt_time_t timeout; nxt_conf_value_t *conf_cmds; nxt_conf_value_t *tickets_conf; + nxt_bool_t ocsp_staple; nxt_tls_conf_t *conf; }; diff --git a/test/test_tls_ocsp.py b/test/test_tls_ocsp.py new file mode 100644 index 000000000..3f29c1f5a --- /dev/null +++ b/test/test_tls_ocsp.py @@ -0,0 +1,342 @@ +import shutil +import subprocess +from pathlib import Path + +import pytest + +from unit.applications.tls import ApplicationTLS +from unit.option import option + +prerequisites = {'modules': {'openssl': 'any'}} + +client = ApplicationTLS() + + +def _have_openssl_ocsp(): + if shutil.which('openssl') is None: + return False + out = subprocess.run( + ['openssl', 'ocsp', '-help'], + capture_output=True, + text=True, + check=False, + ) + return out.returncode in (0, 1) and 'ocsp' in (out.stderr + out.stdout) + + +pytestmark = pytest.mark.skipif( + not _have_openssl_ocsp(), + reason='openssl ocsp tooling not available', +) + + +def _run(args, **kwargs): + return subprocess.check_output( + args, stderr=subprocess.STDOUT, **kwargs + ) + + +def _make_ca(name='ocsp_ca'): + """Self-signed CA usable as both issuer and OCSP signer.""" + cnf = f'''[req] +default_bits = 2048 +prompt = no +encrypt_key = no +distinguished_name = dn +x509_extensions = v3_ca +[dn] +CN = {name} +[v3_ca] +basicConstraints = critical,CA:TRUE +keyUsage = keyCertSign, cRLSign, digitalSignature +extendedKeyUsage = OCSPSigning +''' + cnf_path = Path(option.temp_dir) / f'{name}.cnf' + cnf_path.write_text(cnf, encoding='utf-8') + + _run([ + 'openssl', 'req', '-x509', '-new', '-nodes', + '-config', str(cnf_path), + '-keyout', f'{option.temp_dir}/{name}.key', + '-out', f'{option.temp_dir}/{name}.crt', + '-days', '1', + ]) + + +def _make_leaf(ca='ocsp_ca', name='ocsp_leaf', cn='localhost', san=None): + san = san or cn + leaf_cnf = f'''[req] +default_bits = 2048 +prompt = no +encrypt_key = no +distinguished_name = dn +req_extensions = v3_req +[dn] +CN = {cn} +[v3_ req] +[v3_req] +subjectAltName = @alt +[alt] +DNS.1 = {san} +''' + cnf_path = Path(option.temp_dir) / f'{name}.cnf' + cnf_path.write_text(leaf_cnf, encoding='utf-8') + + ext = f'''subjectAltName = DNS:{san} +''' + ext_path = Path(option.temp_dir) / f'{name}.ext' + ext_path.write_text(ext, encoding='utf-8') + + _run([ + 'openssl', 'req', '-new', '-nodes', + '-config', str(cnf_path), + '-keyout', f'{option.temp_dir}/{name}.key', + '-out', f'{option.temp_dir}/{name}.csr', + ]) + + # Minimal CA database for openssl ca / openssl x509 -req signing. + _run([ + 'openssl', 'x509', '-req', + '-in', f'{option.temp_dir}/{name}.csr', + '-CA', f'{option.temp_dir}/{ca}.crt', + '-CAkey', f'{option.temp_dir}/{ca}.key', + '-CAcreateserial', + '-out', f'{option.temp_dir}/{name}.crt', + '-days', '1', + '-sha256', + '-extfile', str(ext_path), + ]) + + +def _make_ocsp_response(ca='ocsp_ca', leaf='ocsp_leaf', out=None): + """Produce a DER-encoded OCSP response signed by the CA.""" + if out is None: + out = f'{leaf}.ocsp' + + # Build an OCSP request for the leaf against the CA. + _run([ + 'openssl', 'ocsp', + '-issuer', f'{option.temp_dir}/{ca}.crt', + '-cert', f'{option.temp_dir}/{leaf}.crt', + '-reqout', f'{option.temp_dir}/{leaf}.req', + ]) + + # Use -respout to sign a "good" response with the CA key. + # openssl ocsp -reqin -index -CA -rsigner -rkey + index = Path(option.temp_dir) / f'{leaf}_index.txt' + index.write_text('', encoding='utf-8') + + # openssl needs a serial number; add a "valid" entry for our leaf. + serial_hex = subprocess.check_output([ + 'openssl', 'x509', '-in', f'{option.temp_dir}/{leaf}.crt', + '-noout', '-serial', + ], text=True).strip().split('=')[1] + + subj_line = subprocess.check_output([ + 'openssl', 'x509', '-in', f'{option.temp_dir}/{leaf}.crt', + '-noout', '-subject', '-nameopt', 'compat', + ], text=True).strip().split('=', 1)[1].strip() + + not_after = subprocess.check_output([ + 'openssl', 'x509', '-in', f'{option.temp_dir}/{leaf}.crt', + '-noout', '-enddate', + ], text=True).strip().split('=')[1] + + # openssl wants YYMMDDHHMMSSZ; convert from "MMM DD HH:MM:SS YYYY GMT". + import datetime as _dt + end = _dt.datetime.strptime(not_after, '%b %d %H:%M:%S %Y %Z') + expiry = end.strftime('%y%m%d%H%M%SZ') + + index.write_text( + f'V\t{expiry}\t\t{serial_hex}\tunknown\t{subj_line}\n', + encoding='utf-8', + ) + + out_path = f'{option.temp_dir}/{out}' + + _run([ + 'openssl', 'ocsp', + '-index', str(index), + '-CA', f'{option.temp_dir}/{ca}.crt', + '-rsigner', f'{option.temp_dir}/{ca}.crt', + '-rkey', f'{option.temp_dir}/{ca}.key', + '-reqin', f'{option.temp_dir}/{leaf}.req', + '-respout', out_path, + ]) + + return out_path + + +def _upload_bundle(name='ocsp_bundle', leaf='ocsp_leaf', ca='ocsp_ca'): + """Upload leaf+CA chain as a single PEM bundle named ``name``.""" + leaf_pem = Path(f'{option.temp_dir}/{leaf}.crt').read_bytes() + key_pem = Path(f'{option.temp_dir}/{leaf}.key').read_bytes() + ca_pem = Path(f'{option.temp_dir}/{ca}.crt').read_bytes() + return client.conf(key_pem + leaf_pem + ca_pem, f'/certificates/{name}') + + +def _drop_ocsp_into_store(der_path, name='ocsp_bundle'): + """Place .ocsp DER alongside the cert in unit's certificate store.""" + store = Path(option.temp_dir) / 'state' / 'certs' + target = store / f'{name}.ocsp' + target.write_bytes(Path(der_path).read_bytes()) + + +def _s_client_status(port=8080, servername='localhost'): + return subprocess.run( + [ + 'openssl', 's_client', + '-connect', f'127.0.0.1:{port}', + '-servername', servername, + '-status', + '-CAfile', f'{option.temp_dir}/ocsp_ca.crt', + ], + input=b'', + capture_output=True, + timeout=10, + check=False, + ) + + +def _ocsp_serial_from_status(text): + """Extract the SerialNumber from openssl s_client -status output.""" + import re + m = re.search(r'Serial Number:\s*([0-9A-Fa-f]+)', text) + return m.group(1).upper() if m else None + + +def _cert_serial(leaf): + serial = subprocess.check_output([ + 'openssl', 'x509', '-in', f'{option.temp_dir}/{leaf}.crt', + '-noout', '-serial', + ], text=True).strip().split('=')[1] + return serial.upper() + + +@pytest.fixture(autouse=True) +def setup_pki(): + # State (incl. cert store) is preserved across tests in the same module; + # remove any prior .ocsp from the cert store so missing-file tests are + # actually missing. + store = Path(option.temp_dir) / 'state' / 'certs' + for stale in store.glob('*.ocsp'): + stale.unlink() + + _make_ca() + _make_leaf() + der = _make_ocsp_response() + yield der + + +def _apply_listener(tls_obj): + full = { + "listeners": { + "*:8080": {"pass": "routes", "tls": tls_obj}, + }, + "routes": [{"action": {"return": 200}}], + } + return client.conf(full) + + +def test_ocsp_staple_served(): + assert 'success' in _upload_bundle() + _drop_ocsp_into_store(f'{option.temp_dir}/ocsp_leaf.ocsp') + + assert 'success' in _apply_listener({ + "certificate": "ocsp_bundle", + "ocsp_staple": True, + }) + + out = _s_client_status() + text = out.stdout.decode(errors='replace') + assert 'OCSP Response Status: successful' in text + + +def test_ocsp_staple_disabled_omits_extension(): + assert 'success' in _upload_bundle() + _drop_ocsp_into_store(f'{option.temp_dir}/ocsp_leaf.ocsp') + + assert 'success' in _apply_listener({"certificate": "ocsp_bundle"}) + + out = _s_client_status() + text = out.stdout.decode(errors='replace') + assert 'OCSP Response Status: successful' not in text + + +def test_ocsp_staple_invalid_der_rejected(skip_alert): + """Garbage in .ocsp must fail config apply; a misconfigured + listener should never go live with a bogus stapling source.""" + skip_alert(r'ocsp_staple:', r'failed to apply new conf') + + assert 'success' in _upload_bundle() + + store = Path(option.temp_dir) / 'state' / 'certs' + (store / 'ocsp_bundle.ocsp').write_bytes(b'not a valid OCSP response') + + result = _apply_listener({ + "certificate": "ocsp_bundle", + "ocsp_staple": True, + }) + assert 'error' in result or 'success' not in result + + +def test_ocsp_staple_missing_file_no_staple(): + """ocsp_staple=true but no .ocsp sibling: handshake still succeeds, + no status extension is sent.""" + assert 'success' in _upload_bundle() + + assert 'success' in _apply_listener({ + "certificate": "ocsp_bundle", + "ocsp_staple": True, + }) + + out = _s_client_status() + text = out.stdout.decode(errors='replace') + assert 'OCSP Response Status: successful' not in text + + +def test_ocsp_staple_multi_cert_sni_mapping(): + """Two certs on one listener; each .ocsp must map to its own bundle. + + Regression test for the async-OCSP bundle context bug: with + multiple certificates on a single listener, the OCSP response that + returns first must end up attached to the bundle that initiated the + request, not to whichever bundle happens to be at the head of + conf->bundle when the OCSP response arrives. + """ + # certA covers a.example, certB covers b.example. + _make_leaf(name='ocsp_leaf_a', cn='a.example', san='a.example') + _make_leaf(name='ocsp_leaf_b', cn='b.example', san='b.example') + + der_a = _make_ocsp_response(leaf='ocsp_leaf_a', out='bundle_a.ocsp') + der_b = _make_ocsp_response(leaf='ocsp_leaf_b', out='bundle_b.ocsp') + + assert 'success' in _upload_bundle(name='bundle_a', leaf='ocsp_leaf_a') + assert 'success' in _upload_bundle(name='bundle_b', leaf='ocsp_leaf_b') + + _drop_ocsp_into_store(der_a, name='bundle_a') + _drop_ocsp_into_store(der_b, name='bundle_b') + + assert 'success' in _apply_listener({ + "certificate": ["bundle_a", "bundle_b"], + "ocsp_staple": True, + }) + + serial_a = _cert_serial('ocsp_leaf_a') + serial_b = _cert_serial('ocsp_leaf_b') + + out_a = _s_client_status(servername='a.example') + text_a = out_a.stdout.decode(errors='replace') + assert 'OCSP Response Status: successful' in text_a, \ + f'no staple for a.example:\n{text_a[-2000:]}' + stapled_a = _ocsp_serial_from_status(text_a) + assert stapled_a == serial_a, \ + f'a.example stapled wrong serial: got {stapled_a}, want {serial_a}' + + out_b = _s_client_status(servername='b.example') + text_b = out_b.stdout.decode(errors='replace') + assert 'OCSP Response Status: successful' in text_b, \ + f'no staple for b.example:\n{text_b[-2000:]}' + stapled_b = _ocsp_serial_from_status(text_b) + assert stapled_b == serial_b, \ + f'b.example stapled wrong serial: got {stapled_b}, want {serial_b}'