From 83df311f1665fd4ad27847a1048eff52b083b2b7 Mon Sep 17 00:00:00 2001 From: MrIron Date: Mon, 31 Aug 2026 23:29:06 +0200 Subject: [PATCH 1/2] tls_io: event-driven TLS I/O layer; shared logic in one place Rework the TLS layer so all engine / sendq / IPcheck / flood logic lives once in the core and the backends (openssl, gnutls, libtls, none) become thin codecs. Squashes the refactor/tls-io-layer branch. Core -- new ircd/tls_io.c, include/tls_io.h: - socket interest is computed from connection state (level-triggered model), never pushed ad hoc; - the send drain owns the con_rexmit / msgq_excise partial-write handling; - the receive path, the fatal-error teardown (mark the socket dead so we can never fall back to plaintext), and peer-fingerprint storage; - the handshake trust policy (cert-required / verifypeer / fingerprint pin). Backends now only provide read / write / handshake / drop primitives plus setup; they never touch msgq, con_rexmit, cli_tls_fingerprint, FLAG_*, or socket_events (kept honest by a grep invariant). Handshake: driven by ET_READ / ET_WRITE, bounded by a single handshake-deadline timer for both directions, waiting on exactly the blocked direction so a level-triggered writable socket cannot spin. sendq enforcement, IPcheck (before make_client on accept), flood and throttle all apply to TLS via the existing read_packet / send_queued paths -- no parallel I/O path. Fixes carried in: - s_bsd: never touch cptr after read_packet() may have freed it (CPTR_KILLED); - s_bsd: report an outbound handshake failure that arrives as EOF/error; - s_bsd: free the TLS session, Client and IPcheck count on rejected accepts; - send_queued: a zero-credit TLS success (a rexmit drain that excised the last queued message) is progress, not a block -- don't strand an empty connection on send_queues; - tls_openssl: harden every context (NO_RENEGOTIATION | NO_COMPRESSION); - tls_gnutls: refuse client-initiated renegotiation; bound the handshake loop; - engine_kqueue: deliver unread data before EOF. Tests: misbehaving-peer harness (inbound and outbound), s2s TLS burst, real TLS 1.3 KeyUpdate, REHASH cert rotation under a live connection, data-path teardown regressions, and s2s latency / slow-handshake edge cases; TLS_BACKEND (openssl / gnutls / libtls) selection documented. Full tls/ suite green on all three backends. Supersedes #101. --- include/client.h | 10 + include/ircd_tls.h | 108 ++++- include/tls_io.h | 84 ++++ ircd/Makefile.am | 1 + ircd/engine_kqueue.c | 10 +- ircd/s_bsd.c | 260 +++++++++-- ircd/send.c | 12 + ircd/tls_gnutls.c | 318 ++++--------- ircd/tls_io.c | 300 ++++++++++++ ircd/tls_libtls.c | 270 +++-------- ircd/tls_none.c | 39 +- ircd/tls_openssl.c | 421 +++++++---------- tests/README.md | 5 + tests/docker/ircd-tls-hub.conf | 13 + tests/tls/bogus_peer.py | 565 ++++++++++++++++++++++ tests/tls/bogus_server_main.py | 83 ++++ tests/tls/keyupdate_peer.py | 284 ++++++++++++ tests/tls/test_tls_bogus_peer.py | 668 +++++++++++++++++++++++++++ tests/tls/test_tls_datapath_repro.py | 119 +++++ tests/tls/test_tls_keyupdate.py | 162 +++++++ tests/tls/test_tls_rehash.py | 134 ++++++ tests/tls/test_tls_s2s_burst.py | 142 ++++++ 22 files changed, 3247 insertions(+), 761 deletions(-) create mode 100644 include/tls_io.h create mode 100644 ircd/tls_io.c create mode 100644 tests/tls/bogus_peer.py create mode 100644 tests/tls/bogus_server_main.py create mode 100644 tests/tls/keyupdate_peer.py create mode 100644 tests/tls/test_tls_bogus_peer.py create mode 100644 tests/tls/test_tls_datapath_repro.py create mode 100644 tests/tls/test_tls_keyupdate.py create mode 100644 tests/tls/test_tls_rehash.py create mode 100644 tests/tls/test_tls_s2s_burst.py diff --git a/include/client.h b/include/client.h index 3a8b6054..55be63db 100644 --- a/include/client.h +++ b/include/client.h @@ -267,6 +267,8 @@ struct Connection struct Timer con_sasl_timer; /**< SASL timeout timer */ char* con_rexmit; /**< TLS retransmission data */ size_t con_rexmit_len; /**, TLS retransmission length */ + unsigned char con_tls_want_rd; /**< enum ircd_tls_want: a TLS read's blocked direction */ + unsigned char con_tls_want_wr; /**< enum ircd_tls_want: a TLS write's blocked direction */ }; /** Magic constant to identify valid Connection structures. */ @@ -413,6 +415,10 @@ struct Client { #define cli_buffer(cli) con_buffer(cli_connect(cli)) /** Get the Socket structure for sending to a client. */ #define cli_socket(cli) con_socket(cli_connect(cli)) +/** Blocked direction (enum ircd_tls_want) of a TLS read for a client. */ +#define cli_tls_want_rd(cli) con_tls_want_rd(cli_connect(cli)) +/** Blocked direction (enum ircd_tls_want) of a TLS write for a client. */ +#define cli_tls_want_wr(cli) con_tls_want_wr(cli_connect(cli)) /** Get Timer for processing waiting messages from the client. */ #define cli_proc(cli) con_proc(cli_connect(cli)) /** Get auth request for client. */ @@ -498,6 +504,10 @@ struct Client { #define con_buffer(con) ((con)->con_buffer) /** Get the Socket for the connection. */ #define con_socket(con) ((con)->con_socket) +/** Blocked direction (enum ircd_tls_want) of a TLS read on the connection. */ +#define con_tls_want_rd(con) ((con)->con_tls_want_rd) +/** Blocked direction (enum ircd_tls_want) of a TLS write on the connection. */ +#define con_tls_want_wr(con) ((con)->con_tls_want_wr) /** Get the Timer for processing more data from the connection. */ #define con_proc(con) ((con)->con_proc) /** Get the oper privilege set for the connection. */ diff --git a/include/ircd_tls.h b/include/ircd_tls.h index bfdc0f79..c62af710 100644 --- a/include/ircd_tls.h +++ b/include/ircd_tls.h @@ -99,6 +99,20 @@ static inline int ircd_tls_trust_verifies_ca(ircd_tls_trust_policy policy) /** Size of the human-readable reason buffer filled by ircd_tls_negotiate(). */ #define TLS_REASON_LEN 128 +/** Which socket direction a TLS operation is blocked on. + * + * TLS breaks the plaintext assumption that a read waits on readable and a + * write waits on writable: a TLS *write* can be blocked waiting to *read* the + * socket (and vice versa). Backends report the blocked direction with these + * values; the core (tls_io.c) turns them into socket event interest. This is + * the single source of truth for cross-direction I/O — there are no separate + * ad-hoc flags. */ +enum ircd_tls_want { + IRCD_TLS_WANT_NONE = 0, /**< not blocked (or blocked on its natural direction) */ + IRCD_TLS_WANT_READ, /**< the operation needs the socket to become readable */ + IRCD_TLS_WANT_WRITE /**< the operation needs the socket to become writable */ +}; + /* The following variables and functions are provided by ircu2's core * code, not by the TLS interface. */ @@ -226,49 +240,95 @@ void ircd_tls_listen_free(struct Listener *listener); /** ircd_tls_negotiate() attempts to continue an initial TLS handshake * for \a cptr. If the handshake completes, this function calls * \a ClearNegotiatingTLS(cptr) and returns 1. If the handshake failed, - * this function returns -1. Otherwise it updates event flags for the - * client's socket and returns 0. + * this function returns -1. Otherwise it returns 0 and reports through + * \a want which socket direction the handshake is blocked on, so the caller + * can set the socket's event interest. The backend never touches socket + * events itself, and it does not enforce the handshake deadline (a core + * timer does). * * @param[in] cptr Locally connected client to perform handshake for. * @param[out] reason If non-NULL, receives a human-readable failure reason * on a -1 return (empty otherwise). Intended for operator notices and - * the disconnect log, not for the peer (a categorical ERROR line is sent - * to the peer instead). + * the disconnect log, not for the peer. * @param[in] reasonlen Size of the \a reason buffer (see TLS_REASON_LEN). + * @param[out] want If non-NULL, set on a 0 return to the socket direction the + * handshake is waiting on (IRCD_TLS_WANT_READ / IRCD_TLS_WANT_WRITE); + * IRCD_TLS_WANT_NONE otherwise. * \returns 1 on completed handshake, 0 on continuing handshake, -1 on * error. */ -int ircd_tls_negotiate(struct Client *cptr, char *reason, size_t reasonlen); +int ircd_tls_negotiate(struct Client *cptr, char *reason, size_t reasonlen, + enum ircd_tls_want *want); -/** ircd_tls_recv() performs a non-blocking receive of TLS application - * data from \a cptr into \a buf. +/** tls_backend_read() reads TLS application data from \a cptr into \a buf. + * + * Thin per-backend primitive (tls_io_recv() in the core wraps it and records + * the blocked direction). * * @param[in] cptr Locally connected client to read from. * @param[out] buf Buffer to receive application data into. * @param[in] length Length of \a buf. - * @param[out] count_out Number of bytes actually read into \a buf. - * \returns IO_FAILURE on error, IO_BLOCKED if no data is available, or - * IO_SUCCESS if any data was read into \a buf. + * @param[out] count_out Number of bytes read (0 unless IO_SUCCESS). + * @param[out] want On IO_BLOCKED, the socket direction the read is waiting on. + * \returns IO_FAILURE on a fatal error (session torn down), IO_BLOCKED if no + * data is available (with \a want set), or IO_SUCCESS if data was read. + */ +/** Raw peer material a backend hands back after a completed handshake, for the + * core (tls_io.c) to apply trust policy to. The backend does no policy of its + * own beyond what the TLS library enforces during the handshake. */ +struct tls_peer { + int have_cert; /**< peer presented a certificate */ + int verified; /**< PKIX/CA verification passed */ + unsigned char digest[32]; /**< SHA-256 of the peer cert */ + unsigned int digest_len; /**< bytes in \a digest (0 if none) */ + char fp_hex[65]; /**< pre-formatted hex, for libtls */ + char verify_err[TLS_REASON_LEN]; /**< backend-specific verify reason */ +}; + +/** tls_backend_handshake() advances the TLS handshake for \a cptr. + * + * Thin per-backend primitive (ircd_tls_negotiate() in the core wraps it and + * applies the cert-required / verifypeer trust policy and fingerprint storage). + * It performs no teardown and touches no client flags. + * + * @param[out] peer On IO_SUCCESS, filled with the peer's raw material. + * @param[out] reason On IO_FAILURE, a human-readable failure reason. + * @param[out] want On IO_BLOCKED, the socket direction to wait on. + * \returns IO_SUCCESS (handshake complete, \a peer filled), IO_BLOCKED (in + * progress), or IO_FAILURE (fatal; \a reason set, session left for the caller + * to drop). */ -IOResult ircd_tls_recv(struct Client *cptr, char *buf, - unsigned int length, unsigned int *count_out); +IOResult tls_backend_handshake(struct Client *cptr, struct tls_peer *peer, + char *reason, size_t reasonlen, + enum ircd_tls_want *want); + +/** tls_backend_drop() hard-frees \a cptr's TLS session after a fatal error and + * NULLs the socket's session pointer. Unlike ircd_tls_close() it sends no + * close_notify (the session is unusable). The core teardown (tls_io.c) calls + * this; the backend touches no client flags or connection state itself. */ +void tls_backend_drop(struct Client *cptr); + +IOResult tls_backend_read(struct Client *cptr, char *buf, unsigned int length, + unsigned int *count_out, enum ircd_tls_want *want); -/** ircd_tls_sendv() performs a non-blocking send of TLS application - * data from \a buf to \a cptr. +/** tls_backend_write() writes one contiguous buffer to \a cptr's TLS session. * - * This function must accomodate changes to \a buf for successive calls - * to \a cptr. The connection's \a con_rexmit and \a con_rexmit_len - * fields are provided to support this requirement. + * This is a thin per-backend primitive: it does no message-queue or + * retransmit bookkeeping (tls_io_sendv() in the core owns that). It performs + * a single non-blocking record write and classifies the outcome. * * @param[in] cptr Locally connected client to send to. - * @param[in] buf Client's message queue. - * @param[out] count_in Total number of bytes in \a buf at entry. - * @param[out] count_out Number of bytes consumed from \a buf. - * \returns IO_FAILURE on error, IO_BLOCKED if no data could be sent, or - * IO_SUCCESS if any data was written from \a buf. + * @param[in] buf Bytes to write. + * @param[in] len Number of bytes in \a buf. + * @param[out] written Number of bytes accepted (only meaningful on IO_SUCCESS). + * @param[out] want On IO_BLOCKED, the socket direction the write is waiting on. + * \returns IO_SUCCESS if any bytes were written, IO_BLOCKED if none could be + * (with \a want set), or IO_FAILURE on a fatal error (the backend has torn + * the session down). */ -IOResult ircd_tls_sendv(struct Client *cptr, struct MsgQ *buf, - unsigned int *count_in, unsigned int *count_out); +IOResult tls_backend_write(struct Client *cptr, const char *buf, + unsigned int len, unsigned int *written, + enum ircd_tls_want *want); /** Compute base64(SHA1(\a data)) into \a out. * Used for RFC 6455 WebSocket handshakes and similar protocols. diff --git a/include/tls_io.h b/include/tls_io.h new file mode 100644 index 00000000..23734420 --- /dev/null +++ b/include/tls_io.h @@ -0,0 +1,84 @@ +/* + * IRC - Internet Relay Chat, include/tls_io.h + * Copyright (C) 2026 MrIron + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +/** @file + * @brief Core TLS I/O orchestration shared by all backends. + * + * This module owns the mapping from a connection's I/O state to its socket + * event interest. TLS breaks the plaintext assumption that "readable = want + * to read, writable = want to send": a TLS write can be blocked waiting to + * read the socket and vice versa (renegotiation, TLS1.3 KeyUpdate, a partial + * record). Rather than sprinkle special cases through the event loop, the + * desired interest is computed from state in exactly one place here, so the + * socket interest can never drift out of sync with what the TLS layer needs. + */ +#ifndef INCLUDED_tls_io_h +#define INCLUDED_tls_io_h + +#ifndef INCLUDED_ircd_osdep_h +#include "ircd_osdep.h" /* IOResult */ +#endif + +struct Client; +struct MsgQ; + +/** tls_io_sendv() sends as much of \a cptr's message queue as the TLS session + * will accept, owning the partial-write / retransmit bookkeeping so no backend + * has to. It drives the thin per-backend tls_backend_write() primitive. + * + * @param[in] cptr Locally connected TLS client to send to. + * @param[in] buf Client's message queue. + * @param[out] count_in Total number of bytes mapped from \a buf. + * @param[out] count_out Number of bytes consumed from \a buf. + * \returns IO_FAILURE on a fatal error, IO_BLOCKED if nothing could be sent, + * or IO_SUCCESS if any data was written. + */ +IOResult tls_io_sendv(struct Client *cptr, struct MsgQ *buf, + unsigned int *count_in, unsigned int *count_out); + +/** tls_io_recv() reads TLS application data into \a buf, recording the blocked + * direction so the event loop waits on the right event. Drives the thin + * per-backend tls_backend_read() primitive. */ +IOResult tls_io_recv(struct Client *cptr, char *buf, unsigned int length, + unsigned int *count_out); + +/** Record \a cptr's peer-certificate fingerprint from a raw SHA-256 \a digest + * (\a len bytes): store the lowercase hex, or clear it if the digest is not a + * 32-byte SHA-256 or the port suppresses fingerprints (Cloudflare). */ +void tls_io_store_fingerprint(struct Client *cptr, const unsigned char *digest, + unsigned int len); + +/** As tls_io_store_fingerprint(), but from an already-hex fingerprint string + * \a hex (or NULL to clear), for backends that expose the hash pre-formatted. */ +void tls_io_store_fingerprint_hex(struct Client *cptr, const char *hex); + +/** Non-zero if the connection currently wants writable events. + * + * The plaintext rule is "there is queued output or a /LIST in progress". TLS + * overrides it: a write blocked waiting to read must NOT assert writable (the + * level-triggered writable event would spin), and a read blocked waiting to + * write must assert it even with an empty send queue. + */ +int tls_want_writable(struct Client *cptr); + +/** Full socket event interest mask (SOCK_EVENT_*) the connection should hold, + * accounting for TLS cross-direction blocking. Used by the unified event + * driver; readable is always wanted for a live connection. */ +unsigned int tls_desired_events(struct Client *cptr); + +#endif /* INCLUDED_tls_io_h */ diff --git a/ircd/Makefile.am b/ircd/Makefile.am index 665ea4dc..6a51ce76 100644 --- a/ircd/Makefile.am +++ b/ircd/Makefile.am @@ -142,6 +142,7 @@ ircd_SOURCES = \ sasl.c \ send.c \ sline.c \ + tls_io.c \ uping.c \ userload.c \ websocket.c \ diff --git a/ircd/engine_kqueue.c b/ircd/engine_kqueue.c index 92641e98..e16077c2 100644 --- a/ircd/engine_kqueue.c +++ b/ircd/engine_kqueue.c @@ -410,7 +410,15 @@ engine_loop(struct Generators* gen) case SS_CONNECTED: if (evt->filter == EVFILT_READ) { /* data on socket */ Debug((DEBUG_ENGINE, "kqueue: EOF or data to be read")); - event_generate(evt->flags & EV_EOF ? ET_EOF : ET_READ, sock, 0); + /* EV_EOF is set as soon as the peer's FIN arrives, even while + * evt->data bytes are still unread (typically the peer's final + * ERROR/SQUIT line). Deliver those as ET_READ first; the filter is + * level-triggered, so once the buffer is drained the next kevent() + * returns EV_EOF with data == 0 and becomes the real ET_EOF. */ + if ((evt->flags & EV_EOF) && evt->data <= 0) + event_generate(ET_EOF, sock, 0); + else + event_generate(ET_READ, sock, 0); } if (evt->filter == EVFILT_WRITE) { /* socket writable */ Debug((DEBUG_ENGINE, "kqueue: Data can be written")); diff --git a/ircd/s_bsd.c b/ircd/s_bsd.c index 02ffb464..0568667b 100644 --- a/ircd/s_bsd.c +++ b/ircd/s_bsd.c @@ -38,6 +38,7 @@ #include "ircd_snprintf.h" #include "ircd_string.h" #include "ircd_tls.h" +#include "tls_io.h" #include "ircd.h" #include "list.h" #include "listener.h" @@ -107,6 +108,8 @@ const char* const TOS_ERROR_MSG = "error setting TOS for %s: %s"; static void client_sock_callback(struct Event* ev); static void client_timer_callback(struct Event* ev); +static void tls_handshake_timer_arm(struct Client *cptr); +static void tls_negotiation_events(struct Client *cptr, enum ircd_tls_want want); /* @@ -288,8 +291,17 @@ unsigned int deliver_it(struct Client *cptr, struct MsgQ *buf) assert(0 != cptr); - io_result = IsTLS(cptr) && s_tls(&cli_socket(cptr)) - ? ircd_tls_sendv(cptr, buf, &bytes_count, &bytes_written) + /* A TLS client whose session was torn down (a fatal error already freed it) + * must never fall through to the plaintext os_sendv_nonb path, or queued + * data would leak in the clear. The backend marks such a client dead; keep + * the invariant here too. */ + if (IsTLS(cptr) && !s_tls(&cli_socket(cptr))) { + SetFlag(cptr, FLAG_DEADSOCKET); + return 0; + } + + io_result = IsTLS(cptr) + ? tls_io_sendv(cptr, buf, &bytes_count, &bytes_written) : os_sendv_nonb(cli_fd(cptr), buf, &bytes_count, &bytes_written); switch (io_result) { case IO_SUCCESS: @@ -371,6 +383,7 @@ static int completed_connection(struct Client* cptr) s_tls(&cli_socket(cptr)) = tls; SetNegotiatingTLS(cptr); SetTLS(cptr); + tls_handshake_timer_arm(cptr); } /* Are we making progress? Handle the result like tls_negotiate_client(): @@ -379,7 +392,8 @@ static int completed_connection(struct Client* cptr) * to sending PASS/SERVER on a socket without a TLS session. */ if (IsNegotiatingTLS(cptr)) { char reason[TLS_REASON_LEN]; - int res = ircd_tls_negotiate(cptr, reason, sizeof(reason)); + enum ircd_tls_want want = IRCD_TLS_WANT_NONE; + int res = ircd_tls_negotiate(cptr, reason, sizeof(reason), &want); if (res < 0) { sendto_opmask_butone(0, SNO_OLDSNO, "TLS negotiation failed to %s%s%s", @@ -395,8 +409,10 @@ static int completed_connection(struct Client* cptr) } return 0; } - if (res == 0) + if (res == 0) { + tls_negotiation_events(cptr, want); /* wait on the blocked direction */ return 1; /* still negotiating */ + } } } @@ -550,6 +566,7 @@ void add_connection(struct Listener* listener, int fd) { struct Client *new_client; time_t next_target = 0; void *tls; + int ipchecked; const char* const throttle_message = "ERROR :Your host is trying to (re)connect too fast -- throttled\r\n"; @@ -595,33 +612,37 @@ void add_connection(struct Listener* listener, int fd) { } } - if (listener_server(listener)) + /* + * Throttle check before allocating the Client, so a rejected connection + * has nothing to leak but the TLS session freed here. Cloudflare websocket + * ports defer IPcheck until CF-Connecting-IP is known at handshake; the + * socket peer is a Cloudflare edge node. + */ + ipchecked = 0; + if (!listener_server(listener) && !listener_webirc(listener) + && !(listener_websocket(listener) && listener_cloudflare(listener))) { - new_client = make_client(0, STAT_UNKNOWN_SERVER); + if (!IPcheck_local_connect(&addr.addr, &next_target)) + { + ++ServerStats->is_throttled; + write(fd, throttle_message, strlen(throttle_message)); + close(fd); + if (tls) + ircd_tls_close(tls, NULL); + return; + } + ipchecked = 1; } + + if (listener_server(listener)) + new_client = make_client(0, STAT_UNKNOWN_SERVER); else if (listener_webirc(listener)) - { - new_client = make_client(0, STAT_WEBIRC); - } + new_client = make_client(0, STAT_WEBIRC); else - { new_client = make_client(0, listener_websocket(listener) ? STAT_WEBSOCKET : STAT_UNKNOWN_USER); - /* - * Cloudflare websocket ports: defer IPcheck until CF-Connecting-IP is - * known at handshake; the socket peer is a Cloudflare edge node. - */ - if (!(listener_websocket(listener) && listener_cloudflare(listener))) { - if (!IPcheck_local_connect(&addr.addr, &next_target)) - { - ++ServerStats->is_throttled; - write(fd, throttle_message, strlen(throttle_message)); - close(fd); - return; - } - SetIPChecked(new_client); - } - } + if (ipchecked) + SetIPChecked(new_client); /* * Copy ascii address to 'sockhost' just in case. Then we have something @@ -641,6 +662,11 @@ void add_connection(struct Listener* listener, int fd) { write(fd, register_message, strlen(register_message)); close(fd); cli_fd(new_client) = -1; + if (tls) + ircd_tls_close(tls, NULL); + if (IsIPChecked(new_client)) + IPcheck_disconnect(new_client); + free_client(new_client); return; } cli_freeflag(new_client) |= FREEFLAG_SOCKET; @@ -652,7 +678,13 @@ void add_connection(struct Listener* listener, int fd) { { SetTLS(new_client); SetNegotiatingTLS(new_client); - socket_events(&cli_socket(new_client), SOCK_EVENT_WRITABLE); + /* Wait for the ClientHello. The handshake is driven by ET_READ / ET_WRITE + * in client_sock_callback(); tls_negotiation_events() switches to WRITABLE + * only while the backend is blocked on a write. Registering WRITABLE here + * would busy-loop on a level-triggered writable socket until the peer's + * first flight arrived. A silent peer is reaped by the deadline timer. */ + socket_events(&cli_socket(new_client), SOCK_EVENT_READABLE); + tls_handshake_timer_arm(new_client); } Count_newunknown(UserStats); @@ -667,14 +699,20 @@ void add_connection(struct Listener* listener, int fd) { */ void update_write(struct Client* cptr) { - /* If there are messages that need to be sent along, or if the client - * is in the middle of a /list, then we need to tell the engine that - * we're interested in writable events--otherwise, we need to drop - * that interest. + /* Whether we want writable events: for a plaintext connection this is simply + * "there is queued output or an active /LIST". TLS connections can also be + * blocked cross-direction (a write waiting to read, a read waiting to + * write), so that decision is delegated to tls_io.c, which owns the single + * TLS-aware interest rule. Plaintext connections never consult the TLS + * module. Readable interest is managed separately. */ + int want_write = IsTLS(cptr) + ? tls_want_writable(cptr) + : (MsgQLength(&cli_sendQ(cptr)) != 0 || cli_listing(cptr)); + socket_events(&(cli_socket(cptr)), - ((MsgQLength(&cli_sendQ(cptr)) || cli_listing(cptr)) ? - SOCK_ACTION_ADD : SOCK_ACTION_DEL) | SOCK_EVENT_WRITABLE); + (want_write ? SOCK_ACTION_ADD : SOCK_ACTION_DEL) + | SOCK_EVENT_WRITABLE); } /** Non-zero if recvQ exceeds body (maxflood) or tag (CLIENT_TAG_FLOOD) limits. */ @@ -725,11 +763,16 @@ static int read_packet(struct Client *cptr, int socket_ready) ClearExemptThrottle(cptr); } + /* A TLS client whose session was torn down must not read plaintext off the + * socket; treat it as a fatal read (its FLAG_DEADSOCKET is already set). */ + if (IsTLS(cptr) && !s_tls(&cli_socket(cptr))) + return 0; + if (socket_ready && !(IsUser(cptr) && recvq_over_flood(cptr, flood_limit))) { - IOResult io_result = IsTLS(cptr) && s_tls(&cli_socket(cptr)) - ? ircd_tls_recv(cptr, readbuf, sizeof(readbuf), &length) + IOResult io_result = IsTLS(cptr) + ? tls_io_recv(cptr, readbuf, sizeof(readbuf), &length) : os_recv_nonb(cli_fd(cptr), readbuf, sizeof(readbuf), &length); switch (io_result) { case IO_SUCCESS: @@ -743,6 +786,13 @@ static int read_packet(struct Client *cptr, int socket_ready) } break; case IO_BLOCKED: + /* A TLS read blocked waiting to *write* the socket (con_tls_want_rd == + * WANT_WRITE) must assert writable interest, or it is never retried when + * the socket drains (the ET_WRITE arm drives that retry). With a + * non-empty send queue update_write() already keeps WRITABLE, but with + * an empty one nothing else would, so recompute here. */ + if (IsTLS(cptr)) + update_write(cptr); break; case IO_FAILURE: cli_error(cptr) = errno; @@ -1102,20 +1152,60 @@ void init_server_identity(void) /** Notify operators of inbound TLS failures on server ports. */ static void tls_negotiation_failed(struct Client *cptr, const char *reason) { - if (IsServerPort(cptr)) + /* This is the single place that reports a failed TLS handshake, so a failure + * detected on a later socket event (an outbound link parked waiting for the + * server flight, then the read fails) is reported exactly like one detected + * during the connect step itself. */ + if (IsConnecting(cptr)) + sendto_opmask_butone(0, SNO_OLDSNO, "TLS negotiation failed to %s%s%s", + cli_name(cptr), + (reason && reason[0]) ? ": " : "", + reason ? reason : ""); + else if (IsServerPort(cptr)) sendto_opmask_butone(0, SNO_OLDSNO, "TLS negotiation failed from unknown server%s%s", (reason && reason[0]) ? ": " : "", reason ? reason : ""); } +/** Arm the TLS handshake deadline for \a cptr. + * The handshake is driven purely by socket events, so a peer that never + * speaks (or stops mid-handshake) would otherwise sit forever. The + * per-connection process timer (cli_proc) is unused until read_packet() runs, + * which cannot precede the handshake, so it doubles as the deadline; + * tls_handshake_succeeded() cancels it and free_client() deletes it on any + * other exit. */ +static void tls_handshake_timer_arm(struct Client *cptr) +{ + cli_freeflag(cptr) |= FREEFLAG_TIMER; + timer_add(&cli_proc(cptr), client_timer_callback, cli_connect(cptr), + TT_RELATIVE, TLS_HANDSHAKE_TIMEOUT); +} + +/** Wait on exactly the socket direction the handshake reported blocked on. + * A writable socket is level-triggered and almost always ready, so holding + * WRITABLE while waiting for the peer spins; holding READABLE while blocked on + * a write lets a peer that leaves bytes unread re-run the handshake every loop + * pass. Errors (RST) are reported regardless of interest, and a silent peer + * is bounded by the handshake timer either way. */ +static void tls_negotiation_events(struct Client *cptr, enum ircd_tls_want want) +{ + socket_events(&cli_socket(cptr), SOCK_ACTION_SET + | (want == IRCD_TLS_WANT_WRITE ? SOCK_EVENT_WRITABLE + : SOCK_EVENT_READABLE)); +} + /** Run ircd_tls_negotiate() and handle a fatal result. */ static int tls_negotiate_client(struct Client *cptr, char **fmt, char **fallback) { /* static: *fallback is read by the caller after we return, still within the * same (synchronous) socket callback, so a stack buffer would dangle. */ static char reason[TLS_REASON_LEN]; - int res = ircd_tls_negotiate(cptr, reason, sizeof(reason)); + enum ircd_tls_want want = IRCD_TLS_WANT_NONE; + int res = ircd_tls_negotiate(cptr, reason, sizeof(reason), &want); + + if (res == 0) + tls_negotiation_events(cptr, want); if (res < 0) { @@ -1137,6 +1227,10 @@ static int tls_negotiate_client(struct Client *cptr, char **fmt, char **fallback /** Continue client setup after an inbound or outbound TLS handshake completes. */ static void tls_handshake_succeeded(struct Client *cptr) { + /* Drop the handshake deadline armed by tls_handshake_timer_arm(). */ + if (t_onqueue(&cli_proc(cptr))) + timer_del(&cli_proc(cptr)); + if (IsConnecting(cptr)) { /* completed_connection() returns 0 when the link can no longer be set up * (e.g. the Connect block vanished on a rehash mid-handshake). Exit the @@ -1214,6 +1308,19 @@ static void client_sock_callback(struct Event* ev) exit_client_msg(cptr, cptr, &me, "Server %s closed the connection (%s)", cli_name(cptr), cli_serv(cptr)->last_error_msg); return; + } else if (IsNegotiatingTLS(cptr)) { + /* The peer dropped mid-handshake and the kernel surfaced it as EOF/error + * rather than through the TLS read path (tls_negotiate_client()). A + * still-connecting link is STAT_CONNECTING, which exit_client() does not + * treat as IsClient(), so it would otherwise be torn down without telling + * the oper who issued the CONNECT. Report it here exactly like a fatal + * handshake result, then fall through to the normal exit. */ + tls_negotiation_failed(cptr, + cli_error(cptr) ? strerror(cli_error(cptr)) + : "connection closed during handshake"); + ClrFlag(cptr, FLAG_NEGOTIATING_TLS); + fmt = "%s"; + fallback = "TLS negotiation failed"; } else { fmt = "Read error: %s"; fallback = "EOF from client"; @@ -1234,6 +1341,24 @@ static void client_sock_callback(struct Event* ev) return; } ClrFlag(cptr, FLAG_BLOCKED); + /* A TLS read blocked waiting to write asked for this writable event (see + * update_write()). Retry the read now the socket can flush whatever the + * TLS layer owed (e.g. a KeyUpdate response). */ + if (con_tls_want_rd(con) == IRCD_TLS_WANT_WRITE) { + int res = read_packet(cptr, 1); + /* read_packet() may have killed and freed cptr while processing the data + * it just read (an ordinary QUIT, an excess-flood kill, a failed + * websocket upgrade): CPTR_KILLED means the struct is gone, so return + * before anything — the trailing assert included — looks at cptr again. */ + if (res == CPTR_KILLED) + return; + if (res == 0) { + fallback = "EOF from client"; + break; + } + if (IsDead(cptr)) + break; + } if (cli_listing(cptr) && MsgQLength(&(cli_sendQ(cptr))) < 2048) list_next_channels(cptr); Debug((DEBUG_SEND, "Sending queued data to %C", cptr)); @@ -1241,21 +1366,42 @@ static void client_sock_callback(struct Event* ev) break; case ET_READ: /* socket is readable */ - if (!IsDead(cptr)) { - Debug((DEBUG_DEBUG, "Reading data from %C", cptr)); - if (IsNegotiatingTLS(cptr)) { - int res = tls_negotiate_client(cptr, &fmt, &fallback); - if (res < 0) - break; - if (res == 0) { - /* Still negotiating */ - break; - } - /* TLS negotiation succeeded */ - tls_handshake_succeeded(cptr); - } - if (read_packet(cptr, 1) == 0) /* error while reading packet */ + if (IsDead(cptr)) { + /* dead_link() deferred the exit to check_pings(); the readable event is + * level-triggered and would re-fire every loop pass until then, so exit + * now (same context as the ET_EOF case). */ + exit_client(cptr, cptr, &me, cli_info(cptr)); + return; + } + Debug((DEBUG_DEBUG, "Reading data from %C", cptr)); + if (IsNegotiatingTLS(cptr)) { + int res = tls_negotiate_client(cptr, &fmt, &fallback); + if (res < 0) + break; + if (res == 0) + break; /* still negotiating; interest already set */ + /* TLS negotiation succeeded. start_auth() / completed_connection() may + * have exited (and freed) cptr, so do not touch it again; any + * application data already queued re-fires the level-triggered readable + * event. */ + tls_handshake_succeeded(cptr); + return; + } + { + int res = read_packet(cptr, 1); + /* read_packet() may have killed and freed cptr (see the ET_WRITE arm); + * CPTR_KILLED means the struct is gone, so return before touching it. */ + if (res == CPTR_KILLED) + return; + if (res == 0) /* read error; cptr is still alive */ fallback = "EOF from client"; + /* A TLS write blocked waiting to read parked its send queue with writable + * interest dropped (see update_write()). The data we just read may have + * unblocked it, so retry the send now. */ + else if (!IsDead(cptr) && con_tls_want_wr(con) == IRCD_TLS_WANT_READ) { + ClrFlag(cptr, FLAG_BLOCKED); + send_queued(cptr); + } } break; @@ -1306,6 +1452,20 @@ static void client_timer_callback(struct Event* ev) if (!con_freeflag(con) && !cptr) free_connection(con); /* client is being destroyed */ + } else if (IsNegotiatingTLS(cptr)) { + /* Handshake deadline from tls_handshake_timer_arm(). No peer write: a + * stalled handshake must close with a plain EOF, not a plaintext line + * that would corrupt a mid-handshake peer's TLS stream. Exiting from + * inside the timer's own callback is fine: timer_del() is a no-op while it + * is GEN_MARKED and timer_run() destroys the one-shot afterwards. */ + tls_negotiation_failed(cptr, "TLS handshake timed out"); + SetFlag(cptr, FLAG_DEADSOCKET); + ClrFlag(cptr, FLAG_NEGOTIATING_TLS); + if (s_tls(&cli_socket(cptr))) { + ircd_tls_close(s_tls(&cli_socket(cptr)), NULL); + s_tls(&cli_socket(cptr)) = NULL; + } + exit_client_msg(cptr, cptr, &me, "TLS handshake timed out"); } else { Debug((DEBUG_LIST, "Client process timer for %C expired; processing", cptr)); diff --git a/ircd/send.c b/ircd/send.c index 15603714..5c47e146 100644 --- a/ircd/send.c +++ b/ircd/send.c @@ -208,7 +208,19 @@ void send_queued(struct Client *to) char tmp[512]; sprintf(tmp,"Write error: %s",(strerror(cli_error(to))) ? (strerror(cli_error(to))) : "Unknown error" ); dead_link(to, tmp); + return; } + if (!IsBlocked(to)) + /* deliver_it() reported success but credited no bytes: a TLS con_rexmit + * drain removed the last queued message by identity (tls_io_sendv) + * without counting those bytes. That is progress, not a block -- loop + * round so the now-empty sendQ reaches the client_drop_sendq tail below + * instead of being left on send_queues with nothing to send. */ + continue; + /* Genuinely blocked with no bytes sent. Recompute event interest: a TLS + * write waiting to read must drop writable interest here (the backend set + * that state) so the level-triggered writable event does not spin. */ + update_write(to); return; } } diff --git a/ircd/tls_gnutls.c b/ircd/tls_gnutls.c index d024ce29..6fc7f2eb 100644 --- a/ircd/tls_gnutls.c +++ b/ircd/tls_gnutls.c @@ -23,6 +23,7 @@ */ #include "ircd_tls.h" +#include "tls_io.h" #include "ircd.h" #include "ircd_log.h" #include "ircd_snprintf.h" @@ -437,60 +438,45 @@ void ircd_tls_listen_free(struct Listener *listener) } } -int ircd_tls_negotiate(struct Client *cptr, char *reason, size_t reasonlen) +IOResult tls_backend_handshake(struct Client *cptr, struct tls_peer *peer, + char *reason, size_t reasonlen, + enum ircd_tls_want *want) { gnutls_session_t tls; gnutls_x509_crt_t crt; const gnutls_datum_t *datum; size_t len; - int res; + int res, i; unsigned char buf[32]; - const char* const err_certreq = "ERROR :TLS certificate required\r\n"; - const char* const err_certrej = "ERROR :TLS certificate rejected\r\n"; - const char* const err_handshake = "ERROR :TLS handshake failed\r\n"; - - if (reason && reasonlen) - reason[0] = '\0'; tls = s_tls(&cli_socket(cptr)); + if (!tls) + return IO_FAILURE; - if (!tls) { - tls_reason(reason, reasonlen, "TLS setup failed (no session)"); - ClearNegotiatingTLS(cptr); - return -1; - } - - /* Check for handshake timeout - use the constant from header */ - if (CurrentTime - cli_firsttime(cptr) > TLS_HANDSHAKE_TIMEOUT) { - Debug((DEBUG_DEBUG, "GnuTLS handshake timeout for %s", cli_name(cptr))); - /* No peer write: a stalled handshake must close with a plain EOF. */ - tls_reason(reason, reasonlen, "TLS handshake timed out"); - return -1; + /* Non-fatal results other than AGAIN/INTERRUPTED mean "call again now"; the + * bound guards against a misbehaving peer. */ + for (i = 0; i < 16; ++i) + { + res = gnutls_handshake(tls); + if (res >= 0 || res == GNUTLS_E_AGAIN || res == GNUTLS_E_INTERRUPTED + || gnutls_error_is_fatal(res)) + break; } - - res = gnutls_handshake(tls); switch (res) { case GNUTLS_E_INTERRUPTED: case GNUTLS_E_AGAIN: - case GNUTLS_E_WARNING_ALERT_RECEIVED: - case GNUTLS_E_GOT_APPLICATION_DATA: - return 0; + *want = (gnutls_record_get_direction(tls) == 1) ? IRCD_TLS_WANT_WRITE + : IRCD_TLS_WANT_READ; + return IO_BLOCKED; case GNUTLS_E_SUCCESS: datum = gnutls_certificate_get_peers(tls, NULL); - if (ircd_tls_peer_cert_required(cptr) && (!datum || datum->size == 0)) - { - Debug((DEBUG_DEBUG, - "TLS peer certificate required but not presented for %s", - cli_name(cptr))); - tls_reason(reason, reasonlen, - "no peer certificate presented (certificate required)"); - write(cli_fd(cptr), err_certreq, strlen(err_certreq)); - return -1; - } + peer->have_cert = (datum && datum->size > 0); - if (ircd_tls_verifypeer_enabled(cptr) && gnutls_auth_get_type(tls) == GNUTLS_CRT_X509) + /* Verify the peer chain (with the outbound hostname where applicable) so + * the core can enforce verifypeer; the result is advisory for soft ports. */ + if (gnutls_auth_get_type(tls) == GNUTLS_CRT_X509) { unsigned int vstatus = 0; const char *hostname = NULL; @@ -503,118 +489,78 @@ int ircd_tls_negotiate(struct Client *cptr, char *reason, size_t reasonlen) res = gnutls_certificate_verify_peers3(tls, hostname, &vstatus); if (res < 0) - { - Debug((DEBUG_DEBUG, - "TLS peer certificate verification failed for %s: %s", - cli_name(cptr), gnutls_strerror(res))); - tls_reason(reason, reasonlen, "certificate verification error: %s", - gnutls_strerror(res)); - write(cli_fd(cptr), err_certrej, strlen(err_certrej)); - return -1; - } - if (vstatus != 0) + tls_reason(peer->verify_err, sizeof(peer->verify_err), + "certificate verification error: %s", gnutls_strerror(res)); + else if (vstatus != 0) { gnutls_datum_t out; - - Debug((DEBUG_DEBUG, - "TLS peer certificate verification failed for %s (0x%x)", - cli_name(cptr), vstatus)); - if (gnutls_certificate_verification_status_print(vstatus, GNUTLS_CRT_X509, + if (gnutls_certificate_verification_status_print(vstatus, + GNUTLS_CRT_X509, &out, 0) >= 0) { - tls_reason(reason, reasonlen, "certificate verification failed: %s", - out.data); + tls_reason(peer->verify_err, sizeof(peer->verify_err), + "certificate verification failed: %s", out.data); gnutls_free(out.data); } else - tls_reason(reason, reasonlen, + tls_reason(peer->verify_err, sizeof(peer->verify_err), "certificate verification failed (0x%x)", vstatus); - write(cli_fd(cptr), err_certrej, strlen(err_certrej)); - return -1; } - } - - if (!datum) - { - gnutls_session_set_ptr(tls, (void *)1); /* handshake complete: see ircd_tls_close() */ - ClearNegotiatingTLS(cptr); - return 1; - } - - res = gnutls_x509_crt_init(&crt); - if (res) - { - log_write(LS_SYSTEM, L_ERROR, 0, "gnutls_x509_crt_init failed for %s: %d", - cli_name(cptr), res); - return -1; - } - - /* Extract the SHA-256 fingerprint. If the certificate cannot be - * re-parsed or hashed, treat it like "no fingerprint" (len = 0 takes the - * empty-fingerprint branch below) and still complete the handshake, as the - * OpenSSL and libtls backends do. Returning early here would leave - * FLAG_NEGOTIATING_TLS set and wedge the connection. */ - res = gnutls_x509_crt_import(crt, datum, GNUTLS_X509_FMT_DER); - if (res) - { - log_write(LS_SYSTEM, L_ERROR, 0, "gnutls_x509_crt_import failed for %s: %d", - cli_name(cptr), res); - len = 0; + peer->verified = (res >= 0 && vstatus == 0); } else + peer->verified = 1; + + if (datum && gnutls_x509_crt_init(&crt) == 0) { len = sizeof(buf); - res = gnutls_x509_crt_get_fingerprint(crt, GNUTLS_DIG_SHA256, buf, &len); - if (res) + if (gnutls_x509_crt_import(crt, datum, GNUTLS_X509_FMT_DER) == 0 + && gnutls_x509_crt_get_fingerprint(crt, GNUTLS_DIG_SHA256, buf, + &len) == 0 + && len <= sizeof(peer->digest)) { - log_write(LS_SYSTEM, L_ERROR, 0, "gnutls_x509_crt_get_fingerprint failed for %s: %d", - cli_name(cptr), res); - len = 0; + memcpy(peer->digest, buf, len); + peer->digest_len = len; } - } - gnutls_x509_crt_deinit(crt); - - /* Convert buf to hex like OpenSSL version */ - if (len == 32 && !IsCloudflarePort(cptr)) { - char *p = cli_tls_fingerprint(cptr); - for (unsigned int i = 0; i < len; i++) { - sprintf(p + (i * 2), "%02x", buf[i]); - } - p[len * 2] = '\0'; - Debug((DEBUG_DEBUG, "Fingerprint for %s: %s", cli_name(cptr), cli_tls_fingerprint(cptr))); - } - else { - memset(cli_tls_fingerprint(cptr), 0, 65); - if (len == 32 && IsCloudflarePort(cptr)) - Debug((DEBUG_DEBUG, "Skipping TLS fingerprint for Cloudflare port %s", cli_name(cptr))); - else - Debug((DEBUG_DEBUG, "Invalid fingerprint length: %zu", len)); + gnutls_x509_crt_deinit(crt); } - gnutls_session_set_ptr(tls, (void *)1); /* handshake complete: see ircd_tls_close() */ - ClearNegotiatingTLS(cptr); - return 1; + gnutls_session_set_ptr(tls, (void *)1); /* handshake complete: ircd_tls_close() */ + return IO_SUCCESS; default: - Debug((DEBUG_DEBUG, " ... gnutls_handshake() failed -> %s (%d)", - gnutls_strerror(res), res)); - if (gnutls_error_is_fatal(res)) { - Debug((DEBUG_DEBUG, "GnuTLS handshake failed for %s: %s", cli_name(cptr), gnutls_strerror(res))); + if (gnutls_error_is_fatal(res)) + { tls_reason(reason, reasonlen, "%s", gnutls_strerror(res)); - write(cli_fd(cptr), err_handshake, strlen(err_handshake)); - return -1; + return IO_FAILURE; } - return 0; + /* Non-fatal, non-AGAIN: come back via the always-ready writable event. */ + *want = IRCD_TLS_WANT_WRITE; + return IO_BLOCKED; + } +} + +void tls_backend_drop(struct Client *cptr) +{ + gnutls_session_t tls = s_tls(&cli_socket(cptr)); + + if (tls) + { + s_tls(&cli_socket(cptr)) = NULL; + gnutls_deinit(tls); /* no gnutls_bye() after a fatal error */ } } -IOResult ircd_tls_recv(struct Client *cptr, char *buf, - unsigned int length, unsigned int *count_out) + +IOResult tls_backend_read(struct Client *cptr, char *buf, unsigned int length, + unsigned int *count_out, enum ircd_tls_want *want) { gnutls_session_t tls; int res; *count_out = 0; + *want = IRCD_TLS_WANT_NONE; + tls = s_tls(&cli_socket(cptr)); if (!tls) return IO_FAILURE; @@ -625,130 +571,62 @@ IOResult ircd_tls_recv(struct Client *cptr, char *buf, *count_out = res; return IO_SUCCESS; } - /* - * Peer cleanly closed (close_notify) or EOF. gnutls_error_is_fatal(0) is - * false, so treating this as IO_BLOCKED leaves the socket open while the - * client waits for our close_notify (asyncio SSL_SHUTDOWN_TIMEOUT = 30s). - * Match OpenSSL SSL_ERROR_ZERO_RETURN → IO_FAILURE. - */ + /* Peer cleanly closed (close_notify) or EOF. Match OpenSSL ZERO_RETURN. */ if (res == 0) return IO_FAILURE; if (res == GNUTLS_E_REHANDSHAKE) { - res = gnutls_handshake(tls); - if (res >= 0) - return IO_SUCCESS; + /* Refuse renegotiation (matches OpenSSL's SSL_OP_NO_RENEGOTIATION): a + * peer-driven rehandshake is the classic post-handshake CPU / cross- + * direction spin trigger, and a reauth could swap in a different peer + * certificate that cli_tls_fingerprint would never be refreshed against. + * Send a warning no_renegotiation alert and carry on reading. */ + gnutls_alert_send(tls, GNUTLS_AL_WARNING, GNUTLS_A_NO_RENEGOTIATION); + *want = (gnutls_record_get_direction(tls) == 1) ? IRCD_TLS_WANT_WRITE + : IRCD_TLS_WANT_READ; + return IO_BLOCKED; } if (res == GNUTLS_E_INTERRUPTED || res == GNUTLS_E_AGAIN) + { + *want = (gnutls_record_get_direction(tls) == 1) ? IRCD_TLS_WANT_WRITE + : IRCD_TLS_WANT_READ; return IO_BLOCKED; + } return gnutls_error_is_fatal(res) ? IO_FAILURE : IO_BLOCKED; } -IOResult ircd_tls_sendv(struct Client *cptr, struct MsgQ *buf, - unsigned int *count_in, unsigned int *count_out) +IOResult tls_backend_write(struct Client *cptr, const char *buf, + unsigned int len, unsigned int *written, + enum ircd_tls_want *want) { - struct iovec iov[512]; gnutls_session_t tls; - struct Connection *con; ssize_t res; - int ii, count; - int made_progress = 0; - IOResult result; - con = cli_connect(cptr); - tls = s_tls(&con_socket(con)); + *written = 0; + *want = IRCD_TLS_WANT_NONE; + + tls = s_tls(&cli_socket(cptr)); if (!tls) return IO_FAILURE; - /* TODO: Try to use gnutls_record_cork()/_uncork()/_check_corked(). - * The exact semantics of check_corked()'s return value are not clear: - * What does "the size of the corked data" signify relative to what - * has been accepted or must be provided to a future call to - * gnutls_record_send()? - */ - *count_in = 0; - *count_out = 0; - if (con->con_rexmit) + res = gnutls_record_send(tls, buf, len); + if (res > 0) { - /* Drain the unfinished head message, then remove it by identity with - * msgq_excise(). Its bytes are NOT added to *count_out — see the OpenSSL - * backend for why (msgq_delete() would misattribute them to a priority - * message enqueued while we were blocked). */ - const char *rexmit_base = con->con_rexmit; - - while (con->con_rexmit) - { - res = gnutls_record_send(tls, con->con_rexmit, con->con_rexmit_len); - if (res <= 0) { - if (res == GNUTLS_E_INTERRUPTED || res == GNUTLS_E_AGAIN) - return IO_BLOCKED; - *count_out = 0; - return gnutls_error_is_fatal(res) ? IO_FAILURE : IO_BLOCKED; - } - if (res == (int)con->con_rexmit_len) { - con->con_rexmit_len = 0; - con->con_rexmit = NULL; - } else { - con->con_rexmit = (char *)con->con_rexmit + res; - con->con_rexmit_len -= (size_t)res; - } - } - msgq_excise(buf, rexmit_base); - made_progress = 1; - /* fall through to send more from the now-shorter queue */ + *written = (unsigned int)res; + return IO_SUCCESS; } - - // Process remaining messages in the queue - count = msgq_mapiov(buf, iov, sizeof(iov) / sizeof(iov[0]), count_in); - for (ii = 0; ii < count; ++ii) + if (res == GNUTLS_E_INTERRUPTED || res == GNUTLS_E_AGAIN) { - res = gnutls_record_send(tls, iov[ii].iov_base, iov[ii].iov_len); - if (res > 0) - { - *count_out += res; - if (res < (int)iov[ii].iov_len) { - con->con_rexmit = (char *)iov[ii].iov_base + res; - con->con_rexmit_len = iov[ii].iov_len - (size_t)res; - while (con->con_rexmit) - { - res = gnutls_record_send(tls, con->con_rexmit, con->con_rexmit_len); - if (res <= 0) { - if (res == GNUTLS_E_INTERRUPTED || res == GNUTLS_E_AGAIN) - return IO_BLOCKED; - result = gnutls_error_is_fatal(res) ? IO_FAILURE : IO_BLOCKED; - if (result == IO_FAILURE) - *count_out = 0; - return result; - } - *count_out += (unsigned int)res; - if (res == (int)con->con_rexmit_len) { - con->con_rexmit_len = 0; - con->con_rexmit = NULL; - } else { - con->con_rexmit = (char *)con->con_rexmit + res; - con->con_rexmit_len -= (size_t)res; - } - } - } - // else, full message sent, continue to next - continue; - } - - /* We only reach this if the gnutls_record_send failed. */ - if (res == GNUTLS_E_INTERRUPTED || res == GNUTLS_E_AGAIN) { - con->con_rexmit = iov[ii].iov_base; - con->con_rexmit_len = iov[ii].iov_len; - return IO_BLOCKED; - } - result = gnutls_error_is_fatal(res) ? IO_FAILURE : IO_BLOCKED; - if (result == IO_FAILURE) - *count_out = 0; - return result; + *want = (gnutls_record_get_direction(tls) == 1) ? IRCD_TLS_WANT_WRITE + : IRCD_TLS_WANT_READ; + return IO_BLOCKED; } - - return (*count_out || made_progress) ? IO_SUCCESS : IO_BLOCKED; + if (gnutls_error_is_fatal(res)) + return IO_FAILURE; /* core (tls_io_fatal) drops the session */ + return IO_BLOCKED; } + int ircd_tls_sha1_base64(const void *data, size_t len, char *out, size_t outlen) { unsigned char digest[20]; diff --git a/ircd/tls_io.c b/ircd/tls_io.c new file mode 100644 index 00000000..7e013a8e --- /dev/null +++ b/ircd/tls_io.c @@ -0,0 +1,300 @@ +/* + * IRC - Internet Relay Chat, ircd/tls_io.c + * Copyright (C) 2026 MrIron + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +/** @file + * @brief Core TLS I/O orchestration (socket-interest model). + */ +#include "config.h" + +#include "tls_io.h" +#include "client.h" +#include "ircd_events.h" +#include "ircd_tls.h" +#include "msgq.h" +#include "ircd_string.h" +#include +#include "ircd_snprintf.h" + +#include /* struct iovec */ + +/** The base (plaintext) writable desire: queued output or an active /LIST. */ +static int base_want_writable(struct Client *cptr) +{ + return MsgQLength(&cli_sendQ(cptr)) != 0 || cli_listing(cptr); +} + +int tls_want_writable(struct Client *cptr) +{ + /* Called only for TLS connections (update_write() handles plaintext inline). + * Starts from the same base rule as plaintext, then applies the TLS + * cross-direction overrides — the single place that rule lives. */ + int want = base_want_writable(cptr); + + if (con_tls_want_wr(cli_connect(cptr)) == IRCD_TLS_WANT_READ) + want = 0; /* a write waiting to read must not spin */ + if (con_tls_want_wr(cli_connect(cptr)) == IRCD_TLS_WANT_WRITE) + want = 1; /* a write waiting to write needs writable */ + if (con_tls_want_rd(cli_connect(cptr)) == IRCD_TLS_WANT_WRITE) + want = 1; /* a read waiting to write needs writable */ + return want; +} + +unsigned int tls_desired_events(struct Client *cptr) +{ + unsigned int ev = SOCK_EVENT_READABLE; /* always want application input */ + + if (tls_want_writable(cptr)) + ev |= SOCK_EVENT_WRITABLE; + return ev; +} + +/** Core-owned teardown after a fatal backend I/O error: hard-drop the session + * and mark the socket dead, so deliver_it()/read_packet() never fall back to + * the plaintext path and the connection is reaped. Backends do no teardown of + * their own for the read/write paths. */ +static void tls_io_fatal(struct Client *cptr) +{ + struct Connection *con = cli_connect(cptr); + + tls_backend_drop(cptr); + SetFlag(cptr, FLAG_DEADSOCKET); + con_tls_want_rd(con) = IRCD_TLS_WANT_NONE; + con_tls_want_wr(con) = IRCD_TLS_WANT_NONE; +} + +/** Record the direction a blocked write is waiting on, or tear the session + * down on a fatal error — in one place. */ +static void tls_io_note_write(struct Client *cptr, IOResult io, + enum ircd_tls_want want) +{ + if (io == IO_FAILURE) + tls_io_fatal(cptr); + else + con_tls_want_wr(cli_connect(cptr)) = + (io == IO_BLOCKED) ? want : IRCD_TLS_WANT_NONE; +} + +IOResult tls_io_sendv(struct Client *cptr, struct MsgQ *buf, + unsigned int *count_in, unsigned int *count_out) +{ + struct iovec iov[512]; + struct Connection *con = cli_connect(cptr); + enum ircd_tls_want want = IRCD_TLS_WANT_NONE; + unsigned int written; + int ii, count, made_progress = 0; + IOResult io; + + *count_in = 0; + *count_out = 0; + + if (con->con_rexmit) + { + /* con_rexmit is a raw pointer into the head queued message left unfinished + * by a prior partial write. Drain it to completion (a short write does not + * mean the socket is full), then remove that exact message by identity with + * msgq_excise(). These bytes are deliberately NOT added to *count_out: + * msgq_delete() deletes in (partial-normal, prio, normal) order, so + * crediting a whole normal message here would instead delete a priority + * message that jumped ahead while we were blocked. */ + const char *rexmit_base = con->con_rexmit; + + while (con->con_rexmit) + { + io = tls_backend_write(cptr, con->con_rexmit, con->con_rexmit_len, + &written, &want); + if (io != IO_SUCCESS) + { + tls_io_note_write(cptr, io, want); + if (io == IO_FAILURE) + *count_out = 0; + return io; + } + if (written == con->con_rexmit_len) + { + con->con_rexmit_len = 0; + con->con_rexmit = NULL; + } + else + { + con->con_rexmit = (char *)con->con_rexmit + written; + con->con_rexmit_len -= written; + } + } + msgq_excise(buf, rexmit_base); + made_progress = 1; + /* fall through to send more from the now-shorter queue */ + } + + count = msgq_mapiov(buf, iov, sizeof(iov) / sizeof(iov[0]), count_in); + for (ii = 0; ii < count; ++ii) + { + io = tls_backend_write(cptr, iov[ii].iov_base, iov[ii].iov_len, + &written, &want); + if (io == IO_SUCCESS) + { + *count_out += written; + if (written < iov[ii].iov_len) + { + /* Short write: park the remainder in con_rexmit and drain it. These + * bytes are in mapiov order, so they are safe to credit to *count_out. */ + con->con_rexmit = (char *)iov[ii].iov_base + written; + con->con_rexmit_len = iov[ii].iov_len - written; + while (con->con_rexmit) + { + io = tls_backend_write(cptr, con->con_rexmit, con->con_rexmit_len, + &written, &want); + if (io != IO_SUCCESS) + { + tls_io_note_write(cptr, io, want); + if (io == IO_FAILURE) + *count_out = 0; + return io; + } + *count_out += written; + if (written == con->con_rexmit_len) + { + con->con_rexmit_len = 0; + con->con_rexmit = NULL; + } + else + { + con->con_rexmit = (char *)con->con_rexmit + written; + con->con_rexmit_len -= written; + } + } + } + continue; + } + + /* Blocked or fatal before any byte of this iov was accepted. */ + con->con_rexmit = iov[ii].iov_base; + con->con_rexmit_len = iov[ii].iov_len; + tls_io_note_write(cptr, io, want); + if (io == IO_FAILURE) + *count_out = 0; + return io; + } + + if (*count_out || made_progress) + { + con_tls_want_wr(con) = IRCD_TLS_WANT_NONE; + return IO_SUCCESS; + } + return IO_BLOCKED; +} + +IOResult tls_io_recv(struct Client *cptr, char *buf, unsigned int length, + unsigned int *count_out) +{ + enum ircd_tls_want want = IRCD_TLS_WANT_NONE; + IOResult io = tls_backend_read(cptr, buf, length, count_out, &want); + + if (io == IO_FAILURE) + tls_io_fatal(cptr); + else + /* A read blocked waiting to write the socket must ask the event loop for a + * writable event; read_packet()'s IO_BLOCKED path asserts it. */ + con_tls_want_rd(cli_connect(cptr)) = + (io == IO_BLOCKED) ? want : IRCD_TLS_WANT_NONE; + return io; +} + +void tls_io_store_fingerprint(struct Client *cptr, const unsigned char *digest, + unsigned int len) +{ + char *p = cli_tls_fingerprint(cptr); + + if (len == 32 && !IsCloudflarePort(cptr)) + { + unsigned int i; + for (i = 0; i < len; ++i) + sprintf(p + i * 2, "%02x", digest[i]); + p[len * 2] = '\0'; + } + else + memset(p, 0, 65); +} + +void tls_io_store_fingerprint_hex(struct Client *cptr, const char *hex) +{ + char *p = cli_tls_fingerprint(cptr); + + if (hex && hex[0] && strlen(hex) <= 64 && !IsCloudflarePort(cptr)) + ircd_strncpy(p, hex, 64); + else + memset(p, 0, 65); +} + +/** Set \a reason to a plain message (bounded), the core's one reason writer. */ +static void tls_io_reason(char *reason, size_t reasonlen, const char *msg) +{ + if (reason && reasonlen) + ircd_snprintf(0, reason, reasonlen, "%s", msg); +} + +int ircd_tls_negotiate(struct Client *cptr, char *reason, size_t reasonlen, + enum ircd_tls_want *want) +{ + struct tls_peer peer; + IOResult io; + + if (reason && reasonlen) + reason[0] = '\0'; + if (want) + *want = IRCD_TLS_WANT_NONE; + + /* No session left to negotiate: fail rather than report success, or + * start_auth() would run on every subsequent event while FLAG_NEGOTIATING_TLS + * stays set. */ + if (!s_tls(&cli_socket(cptr))) + { + tls_io_reason(reason, reasonlen, "TLS setup failed (no session)"); + ClearNegotiatingTLS(cptr); + return -1; + } + + memset(&peer, 0, sizeof(peer)); + io = tls_backend_handshake(cptr, &peer, reason, reasonlen, want); + if (io == IO_BLOCKED) + return 0; + if (io == IO_FAILURE) + return -1; /* reason filled by the backend; the caller drops the session */ + + /* Handshake complete — apply the trust policy the backend does not. */ + if (ircd_tls_peer_cert_required(cptr) && !peer.have_cert) + { + tls_io_reason(reason, reasonlen, + "no peer certificate presented (certificate required)"); + return -1; + } + if (ircd_tls_verifypeer_enabled(cptr) && !peer.verified) + { + tls_io_reason(reason, reasonlen, + peer.verify_err[0] ? peer.verify_err + : "certificate verification failed"); + return -1; + } + + if (peer.digest_len) + tls_io_store_fingerprint(cptr, peer.digest, peer.digest_len); + else + tls_io_store_fingerprint_hex(cptr, peer.fp_hex[0] ? peer.fp_hex : NULL); + + ClearNegotiatingTLS(cptr); + return 1; +} diff --git a/ircd/tls_libtls.c b/ircd/tls_libtls.c index ee8237df..b826fae9 100644 --- a/ircd/tls_libtls.c +++ b/ircd/tls_libtls.c @@ -28,6 +28,7 @@ #include "ircd_snprintf.h" #include "ircd_string.h" #include "ircd_tls.h" +#include "tls_io.h" #include "listener.h" #include "s_auth.h" #include "send.h" @@ -449,23 +450,6 @@ void ircd_tls_close(void *ctx, const char *message) tls_free(ctx); } -static IOResult tls_handle_error(struct Client *cptr, struct tls *tls, int err) -{ - switch (err) { - case TLS_WANT_POLLIN: - case TLS_WANT_POLLOUT: - return IO_BLOCKED; - - default: - /* Fatal error */ - Debug((DEBUG_DEBUG, "tls fatal error for %s: %s", cli_name(cptr), tls_error(tls))); - break; - } - tls_free(tls); - s_tls(&cli_socket(cptr)) = NULL; - return IO_FAILURE; -} - int ircd_tls_listen(struct Listener *listener) { struct tls_config *cfg; @@ -527,111 +511,68 @@ void ircd_tls_listen_free(struct Listener *listener) } } -int ircd_tls_negotiate(struct Client *cptr, char *reason, size_t reasonlen) +IOResult tls_backend_handshake(struct Client *cptr, struct tls_peer *peer, + char *reason, size_t reasonlen, + enum ircd_tls_want *want) { const char *hash; struct tls *tls; int res; - const char* const err_certreq = "ERROR :TLS certificate required\r\n"; - const char* const err_certrej = "ERROR :TLS certificate rejected\r\n"; - const char* const err_handshake = "ERROR :TLS handshake failed\r\n"; - - if (reason && reasonlen) - reason[0] = '\0'; tls = s_tls(&cli_socket(cptr)); - if (!tls) { - tls_reason(reason, reasonlen, "TLS setup failed (no session)"); - ClearNegotiatingTLS(cptr); - return -1; - } - - /* Check for handshake timeout */ - if (CurrentTime - cli_firsttime(cptr) > TLS_HANDSHAKE_TIMEOUT) { - Debug((DEBUG_DEBUG, "libtls handshake timeout for %s", cli_name(cptr))); - /* No peer write: a stalled handshake must close with a plain EOF. */ - tls_reason(reason, reasonlen, "TLS handshake timed out"); - return -1; - } - - Debug((DEBUG_DEBUG, "libtls handshake for %s", cli_name(cptr))); + if (!tls) + return IO_FAILURE; res = tls_handshake(tls); if (res == 0) { + /* libtls enforces the configured verification during the handshake, so a + * completed handshake is verified; report the material for the core. */ hash = tls_peer_cert_hash(tls); - if (ircd_tls_peer_cert_required(cptr) && (!hash || !hash[0])) - { - Debug((DEBUG_DEBUG, - "TLS peer certificate required but not presented for %s", - cli_name(cptr))); - tls_reason(reason, reasonlen, - "no peer certificate presented (certificate required)"); - write(cli_fd(cptr), err_certreq, strlen(err_certreq)); - return -1; - } - - if (ircd_tls_verifypeer_enabled(cptr) && (!hash || !hash[0])) - { - Debug((DEBUG_DEBUG, - "TLS peer certificate verification failed for %s", - cli_name(cptr))); - tls_reason(reason, reasonlen, "peer certificate could not be verified"); - write(cli_fd(cptr), err_certrej, strlen(err_certrej)); - return -1; - } - - ClearNegotiatingTLS(cptr); - - if (hash && !ircd_strncmp(hash, "SHA256:", 7) && !IsCloudflarePort(cptr)) - { - /* Convert the hash to our fingerprint format */ - if (strlen(hash + 7) <= 64) { - ircd_strncpy(cli_tls_fingerprint(cptr), hash + 7, 64); - Debug((DEBUG_DEBUG, "Fingerprint for %s: %s", cli_name(cptr), cli_tls_fingerprint(cptr))); - } else { - memset(cli_tls_fingerprint(cptr), 0, 65); - Debug((DEBUG_DEBUG, "Invalid fingerprint length: %zu", strlen(hash + 7))); - } - } else { - memset(cli_tls_fingerprint(cptr), 0, 65); - if (hash && !ircd_strncmp(hash, "SHA256:", 7) && IsCloudflarePort(cptr)) - Debug((DEBUG_DEBUG, "Skipping TLS fingerprint for Cloudflare port %s", cli_name(cptr))); - else - Debug((DEBUG_DEBUG, "Failed to get fingerprint for %s", cli_name(cptr))); - } - - return 1; + peer->have_cert = (hash && hash[0]); + peer->verified = 1; + if (hash && !ircd_strncmp(hash, "SHA256:", 7)) + ircd_strncpy(peer->fp_hex, hash + 7, sizeof(peer->fp_hex) - 1); + return IO_SUCCESS; } - - if (res == TLS_WANT_POLLIN || res == TLS_WANT_POLLOUT) { - return 0; /* Handshake in progress */ + if (res == TLS_WANT_POLLIN) + { + *want = IRCD_TLS_WANT_READ; + return IO_BLOCKED; } - + if (res == TLS_WANT_POLLOUT) { - const char *tls_err = tls_error(tls); /* before tls_handle_error frees it */ - IOResult tls_result; + *want = IRCD_TLS_WANT_WRITE; + return IO_BLOCKED; + } - if (tls_err) - tls_reason(reason, reasonlen, "%s", tls_err); - tls_result = tls_handle_error(cptr, tls, res); - if (tls_result == IO_FAILURE) { - Debug((DEBUG_DEBUG, "TLS handshake failed for %s", cli_name(cptr))); - if (!tls_err) - tls_reason(reason, reasonlen, "handshake error"); - write(cli_fd(cptr), err_handshake, strlen(err_handshake)); - return -1; - } - /* tls_result == IO_BLOCKED - handshake still in progress */ - return 0; + { + const char *tls_err = tls_error(tls); + tls_reason(reason, reasonlen, "%s", tls_err ? tls_err : "handshake error"); + } + return IO_FAILURE; +} + +void tls_backend_drop(struct Client *cptr) +{ + struct tls *tls = s_tls(&cli_socket(cptr)); + + if (tls) + { + s_tls(&cli_socket(cptr)) = NULL; + tls_free(tls); } } -IOResult ircd_tls_recv(struct Client *cptr, char *buf, - unsigned int length, unsigned int *count_out) + +IOResult tls_backend_read(struct Client *cptr, char *buf, unsigned int length, + unsigned int *count_out, enum ircd_tls_want *want) { struct tls *tls; - int res; + ssize_t res; + + *count_out = 0; + *want = IRCD_TLS_WANT_NONE; tls = s_tls(&cli_socket(cptr)); if (!tls) @@ -640,113 +581,56 @@ IOResult ircd_tls_recv(struct Client *cptr, char *buf, res = tls_read(tls, buf, length); if (res > 0) { - *count_out = res; + *count_out = (unsigned int)res; return IO_SUCCESS; } - - return tls_handle_error(cptr, tls, res); + if (res == TLS_WANT_POLLIN) + { + *want = IRCD_TLS_WANT_READ; + return IO_BLOCKED; + } + if (res == TLS_WANT_POLLOUT) + { + *want = IRCD_TLS_WANT_WRITE; + return IO_BLOCKED; + } + return IO_FAILURE; /* core (tls_io_fatal) drops the session */ } -IOResult ircd_tls_sendv(struct Client *cptr, struct MsgQ *buf, - unsigned int *count_in, unsigned int *count_out) +IOResult tls_backend_write(struct Client *cptr, const char *buf, + unsigned int len, unsigned int *written, + enum ircd_tls_want *want) { - struct iovec iov[512]; struct tls *tls; - struct Connection *con; - IOResult result = IO_BLOCKED; - int ii, count, res; - int made_progress = 0; + ssize_t res; - con = cli_connect(cptr); - tls = s_tls(&con_socket(con)); + *written = 0; + *want = IRCD_TLS_WANT_NONE; + + tls = s_tls(&cli_socket(cptr)); if (!tls) return IO_FAILURE; - /* tls_write() does not document any restriction on retries. */ - *count_in = 0; - *count_out = 0; - if (con->con_rexmit) + res = tls_write(tls, buf, len); + if (res > 0) { - /* Drain the unfinished head message, then remove it by identity with - * msgq_excise(). Its bytes are NOT added to *count_out — see the OpenSSL - * backend for why (msgq_delete() would misattribute them to a priority - * message enqueued while we were blocked). */ - const char *rexmit_base = con->con_rexmit; - - while (con->con_rexmit) - { - res = tls_write(tls, con->con_rexmit, con->con_rexmit_len); - if (res <= 0) { - if (res == TLS_WANT_POLLIN || res == TLS_WANT_POLLOUT) - return IO_BLOCKED; - result = tls_handle_error(cptr, tls, res); - if (result == IO_FAILURE) - *count_out = 0; - return result; - } - if (res == (int)con->con_rexmit_len) { - con->con_rexmit_len = 0; - con->con_rexmit = NULL; - } else { - con->con_rexmit = (char *)con->con_rexmit + res; - con->con_rexmit_len -= (size_t)res; - } - } - msgq_excise(buf, rexmit_base); - made_progress = 1; - /* fall through to send more from the now-shorter queue */ + *written = (unsigned int)res; + return IO_SUCCESS; } - - /* Process remaining messages in the queue. */ - count = msgq_mapiov(buf, iov, sizeof(iov) / sizeof(iov[0]), count_in); - for (ii = 0; ii < count; ++ii) + if (res == TLS_WANT_POLLIN) { - res = tls_write(tls, iov[ii].iov_base, iov[ii].iov_len); - if (res > 0) - { - *count_out += res; - if (res < (int)iov[ii].iov_len) { - con->con_rexmit = (char *)iov[ii].iov_base + res; - con->con_rexmit_len = iov[ii].iov_len - (size_t)res; - while (con->con_rexmit) - { - res = tls_write(tls, con->con_rexmit, con->con_rexmit_len); - if (res <= 0) { - if (res == TLS_WANT_POLLIN || res == TLS_WANT_POLLOUT) - return IO_BLOCKED; - result = tls_handle_error(cptr, tls, res); - if (result == IO_FAILURE) - *count_out = 0; - return result; - } - *count_out += (unsigned int)res; - if (res == (int)con->con_rexmit_len) { - con->con_rexmit_len = 0; - con->con_rexmit = NULL; - } else { - con->con_rexmit = (char *)con->con_rexmit + res; - con->con_rexmit_len -= (size_t)res; - } - } - } - continue; - } - - /* tls_write failed before any bytes of this iov. */ - if (res == TLS_WANT_POLLIN || res == TLS_WANT_POLLOUT) { - con->con_rexmit = iov[ii].iov_base; - con->con_rexmit_len = iov[ii].iov_len; - return IO_BLOCKED; - } - result = tls_handle_error(cptr, tls, res); - if (result == IO_FAILURE) - *count_out = 0; - return result; + *want = IRCD_TLS_WANT_READ; + return IO_BLOCKED; } - - return (*count_out || made_progress) ? IO_SUCCESS : IO_BLOCKED; + if (res == TLS_WANT_POLLOUT) + { + *want = IRCD_TLS_WANT_WRITE; + return IO_BLOCKED; + } + return IO_FAILURE; /* core (tls_io_fatal) drops the session */ } + int ircd_tls_sha1_base64(const void *data, size_t len, char *out, size_t outlen) { return ircd_sha1_base64(data, len, out, outlen); diff --git a/ircd/tls_none.c b/ircd/tls_none.c index 8c7a38d6..c85a5884 100644 --- a/ircd/tls_none.c +++ b/ircd/tls_none.c @@ -84,24 +84,45 @@ void ircd_tls_listen_free(struct Listener *listener) (void)listener; } -int ircd_tls_negotiate(struct Client *cptr, char *reason, size_t reasonlen) +IOResult tls_backend_handshake(struct Client *cptr, struct tls_peer *peer, + char *reason, size_t reasonlen, + enum ircd_tls_want *want) { + (void)cptr; + (void)peer; (void)reason; (void)reasonlen; - ClearNegotiatingTLS(cptr); - return 1; + (void)want; + return IO_FAILURE; +} + +void tls_backend_drop(struct Client *cptr) +{ + (void)cptr; } -IOResult ircd_tls_recv(struct Client *cptr, char *buf, - unsigned int length, unsigned int *count_out) + +IOResult tls_backend_read(struct Client *cptr, char *buf, unsigned int length, + unsigned int *count_out, enum ircd_tls_want *want) { - return os_recv_nonb(cli_fd(cptr), buf, length, count_out); + (void)cptr; + (void)buf; + (void)length; + *count_out = 0; + *want = IRCD_TLS_WANT_NONE; + return IO_FAILURE; } -IOResult ircd_tls_sendv(struct Client *cptr, struct MsgQ *buf, - unsigned int *count_in, unsigned int *count_out) +IOResult tls_backend_write(struct Client *cptr, const char *buf, + unsigned int len, unsigned int *written, + enum ircd_tls_want *want) { - return os_sendv_nonb(cli_fd(cptr), buf, count_in, count_out); + (void)cptr; + (void)buf; + (void)len; + *written = 0; + *want = IRCD_TLS_WANT_NONE; + return IO_FAILURE; } int ircd_tls_sha1_base64(const void *data, size_t len, char *out, size_t outlen) diff --git a/ircd/tls_openssl.c b/ircd/tls_openssl.c index 90955204..1a5171db 100644 --- a/ircd/tls_openssl.c +++ b/ircd/tls_openssl.c @@ -28,6 +28,7 @@ #include "ircd_snprintf.h" #include "ircd_string.h" #include "ircd_tls.h" +#include "tls_io.h" #include "ircd.h" #include "listener.h" #include "s_conf.h" @@ -228,6 +229,20 @@ static void openssl_apply_verify_policy(SSL *tls, ircd_tls_trust_policy policy) SSL_set_verify(tls, mode, verify_ca ? NULL : openssl_fingerprint_verify_callback); } +/** Apply the I/O mode and hardening options every ircd SSL_CTX needs. + * SSL_OP_NO_RENEGOTIATION removes the only way a peer can drive a post- + * handshake SSL_write into SSL_ERROR_WANT_READ on TLS 1.2 (a CPU-spin + * trigger); SSL_OP_NO_COMPRESSION disables CRIME-style record compression. */ +static void openssl_harden_ctx(SSL_CTX *ctx) +{ + SSL_CTX_set_mode(ctx, SSL_MODE_ENABLE_PARTIAL_WRITE + | SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER); +#ifdef SSL_OP_NO_RENEGOTIATION + SSL_CTX_set_options(ctx, SSL_OP_NO_RENEGOTIATION); +#endif + SSL_CTX_set_options(ctx, SSL_OP_NO_COMPRESSION); +} + static int openssl_configure_server_ctx(SSL_CTX *ctx, const char *ciphers, const char *cacertfile, const char *cacertdir, @@ -259,8 +274,7 @@ static int openssl_configure_server_ctx(SSL_CTX *ctx, const char *ciphers, SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION); openssl_set_verify_policy(ctx, policy); - SSL_CTX_set_mode(ctx, SSL_MODE_ENABLE_PARTIAL_WRITE - | SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER); + openssl_harden_ctx(ctx); str = ciphers; if (EmptyString(str)) @@ -301,8 +315,7 @@ static int openssl_configure_client_ctx(SSL_CTX *ctx, const char *ciphers, SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION); openssl_set_verify_policy(ctx, policy); - SSL_CTX_set_mode(ctx, SSL_MODE_ENABLE_PARTIAL_WRITE - | SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER); + openssl_harden_ctx(ctx); str = ciphers; if (EmptyString(str)) @@ -465,8 +478,8 @@ int ircd_tls_init(void) /* Default connect context: outbound S2S without verifypeer (REQUIRE_SOFT). */ openssl_set_verify_policy(new_client_ctx, TLS_TRUST_REQUIRE_SOFT); - SSL_CTX_set_mode(new_server_ctx, SSL_MODE_ENABLE_PARTIAL_WRITE | SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER); - SSL_CTX_set_mode(new_client_ctx, SSL_MODE_ENABLE_PARTIAL_WRITE | SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER); + openssl_harden_ctx(new_server_ctx); + openssl_harden_ctx(new_client_ctx); /* Configure ciphers */ str = feature_str(FEAT_TLS_CIPHERS); @@ -687,313 +700,193 @@ void ircd_tls_listen_free(struct Listener *listener) } } -static IOResult ssl_handle_error(struct Client *cptr, SSL *tls, int res, int orig_errno) -{ - int err = SSL_get_error(tls, res); - - Debug((DEBUG_DEBUG, "ssl_handle_error: SSL_get_error=%d, res=%d, orig_errno=%d for %C", - err, res, orig_errno, cptr)); - - switch (err) - { - case SSL_ERROR_WANT_READ: - return IO_BLOCKED; - - case SSL_ERROR_WANT_WRITE: - return IO_BLOCKED; - - case SSL_ERROR_SYSCALL: - if (orig_errno == EINTR || orig_errno == EAGAIN || orig_errno == EWOULDBLOCK) - return IO_BLOCKED; - break; - case SSL_ERROR_ZERO_RETURN: - Debug((DEBUG_DEBUG, "SSL_ERROR_ZERO_RETURN: peer closed connection for %C", cptr)); - if (SSL_shutdown(tls) == 0) - SSL_shutdown(tls); - break; - - default: - /* Fatal SSL error */ - Debug((DEBUG_ERROR, "SSL fatal error %d for %C", err, cptr)); - unsigned long e; - while ((e = ERR_get_error()) != 0) { - Debug((DEBUG_ERROR, "SSL ERROR: %s", ERR_error_string(e, NULL))); - } - break; - } - - /* Fatal error - clean up SSL context */ - if (tls && s_tls(&cli_socket(cptr)) == tls) { - Debug((DEBUG_ERROR, "SSL fall-through fatal error %d for %C", err, cptr)); - s_tls(&cli_socket(cptr)) = NULL; - /* Do not call SSL_shutdown() after fatal errors */ - SSL_free(tls); - } - - return IO_FAILURE; -} - -int ircd_tls_negotiate(struct Client *cptr, char *reason, size_t reasonlen) +/** Classify a failed SSL_write() from the send path and record the socket + * direction it is blocked on. A write blocked on SSL_ERROR_WANT_READ must NOT + * keep writable interest asserted (update_write() drops it), or the level- + * triggered writable event spins; the always-on readable event drives the + * retry. Any other block is an ordinary "wants write". */ +IOResult tls_backend_handshake(struct Client *cptr, struct tls_peer *peer, + char *reason, size_t reasonlen, + enum ircd_tls_want *want) { SSL *tls; X509 *cert; - unsigned int len; - int res; - unsigned char buf[EVP_MAX_MD_SIZE]; - const char* const err_certreq = "ERROR :TLS certificate required\r\n"; - const char* const err_certrej = "ERROR :TLS certificate rejected\r\n"; - const char* const err_handshake = "ERROR :TLS handshake failed\r\n"; - - if (reason && reasonlen) - reason[0] = '\0'; + int res, orig_errno, sslerr; + long vr; + unsigned long queued; tls = s_tls(&cli_socket(cptr)); - if (!tls) { - /* No session left to negotiate; do not report success or start_auth - * will be invoked on every subsequent ET_WRITE while FLAG_NEGOTIATING_TLS - * remains set. */ - tls_reason(reason, reasonlen, "TLS setup failed (no session)"); - ClearNegotiatingTLS(cptr); - return -1; - } - - /* Check for handshake timeout */ - if (CurrentTime - cli_firsttime(cptr) > TLS_HANDSHAKE_TIMEOUT) { - Debug((DEBUG_DEBUG, "SSL handshake timeout for fd=%d", cli_fd(cptr))); - /* No peer write: a stalled handshake must close with a plain EOF, not a - * plaintext line (which would corrupt a mid-handshake peer's TLS stream). */ - tls_reason(reason, reasonlen, "TLS handshake timed out"); - return -1; - } + if (!tls) + return IO_FAILURE; - /* For client connections, use SSL_connect; for server, SSL_accept. */ - if (SSL_is_server(tls)) - res = SSL_accept(tls); - else - res = SSL_connect(tls); + ERR_clear_error(); + res = SSL_is_server(tls) ? SSL_accept(tls) : SSL_connect(tls); if (res == 1) { cert = SSL_get_peer_certificate(tls); - if (ircd_tls_peer_cert_required(cptr) && !cert) - { - Debug((DEBUG_DEBUG, "TLS peer certificate required but not presented for %C", - cptr)); - tls_reason(reason, reasonlen, - "no peer certificate presented (certificate required)"); - write(cli_fd(cptr), err_certreq, strlen(err_certreq)); - return -1; - } - - if (ircd_tls_verifypeer_enabled(cptr)) - { - long vr = SSL_get_verify_result(tls); - - if (vr != X509_V_OK) - { - Debug((DEBUG_DEBUG, - "TLS peer certificate verification failed for %C: %ld", - cptr, vr)); - tls_reason(reason, reasonlen, "certificate verification failed: %s", - X509_verify_cert_error_string(vr)); - if (cert) - X509_free(cert); - write(cli_fd(cptr), err_certrej, strlen(err_certrej)); - return -1; - } - } - - Debug((DEBUG_DEBUG, "SSL handshake success for fd=%d", cli_fd(cptr))); + peer->have_cert = (cert != NULL); + peer->verified = (SSL_get_verify_result(tls) == X509_V_OK); + if (!peer->verified) + tls_reason(peer->verify_err, sizeof(peer->verify_err), + "certificate verification failed: %s", + X509_verify_cert_error_string(SSL_get_verify_result(tls))); if (cert) { - Debug((DEBUG_DEBUG, "SSL_get_peer_certificate success for fd=%d", cli_fd(cptr))); - len = sizeof(buf); - res = X509_digest(cert, fp_digest, buf, &len); - X509_free(cert); - if (res != 1) + unsigned char buf[EVP_MAX_MD_SIZE]; + unsigned int len = sizeof(buf); + if (X509_digest(cert, fp_digest, buf, &len) == 1 + && len <= sizeof(peer->digest)) { - log_write(LS_SYSTEM, L_ERROR, 0, "X509_digest failed for %C: %d", - cptr, res); - } - else if (len == 32 && !IsCloudflarePort(cptr)) { - /* Convert fingerprint to lowercase hex */ - char *p = cli_tls_fingerprint(cptr); - for (unsigned int i = 0; i < len; i++) { - sprintf(p + (i * 2), "%02x", buf[i]); - } - p[len * 2] = '\0'; - Debug((DEBUG_DEBUG, "Fingerprint for %C: %s", cptr, cli_tls_fingerprint(cptr))); - } - else { - memset(cli_tls_fingerprint(cptr), 0, 65); - if (len == 32 && IsCloudflarePort(cptr)) - Debug((DEBUG_DEBUG, "Skipping TLS fingerprint for Cloudflare port %C", cptr)); - else - Debug((DEBUG_DEBUG, "Invalid fingerprint length: %u", len)); + memcpy(peer->digest, buf, len); + peer->digest_len = len; } + else + log_write(LS_SYSTEM, L_ERROR, 0, "X509_digest failed for %C", cptr); + X509_free(cert); } - ClearNegotiatingTLS(cptr); - /* X509_digest may have overwritten res; handshake itself succeeded. */ - return 1; + return IO_SUCCESS; } + orig_errno = errno; + sslerr = SSL_get_error(tls, res); + vr = SSL_get_verify_result(tls); + queued = ERR_peek_last_error(); + + if (sslerr == SSL_ERROR_WANT_READ) { - int orig_errno = errno; - int sslerr = SSL_get_error(tls, res); - long vr = SSL_get_verify_result(tls); - unsigned long queued = ERR_peek_last_error(); /* before ssl_handle_error drains */ - /* Handshake in progress. */ - IOResult ssl_result = ssl_handle_error(cptr, tls, res, orig_errno); - if (ssl_result == IO_FAILURE) { - Debug((DEBUG_DEBUG, "SSL handshake failed for fd=%d", cli_fd(cptr))); - if (vr != X509_V_OK) - /* Handshake aborted on certificate verification: report the exact - * X509 error. SSL_get_verify_result() is set during verification, - * so it is available even though SSL_accept()/SSL_connect() failed. */ - tls_reason(reason, reasonlen, "%s", X509_verify_cert_error_string(vr)); - else if (queued) - tls_reason(reason, reasonlen, "%s", ERR_reason_error_string(queued)); - else if (sslerr == SSL_ERROR_ZERO_RETURN) - tls_reason(reason, reasonlen, "peer closed connection"); - else if (sslerr == SSL_ERROR_SYSCALL && orig_errno) - tls_reason(reason, reasonlen, "%s", strerror(orig_errno)); - else - tls_reason(reason, reasonlen, "handshake error"); - write(cli_fd(cptr), err_handshake, strlen(err_handshake)); - return -1; - } - /* ssl_result == IO_BLOCKED - handshake still in progress */ - return 0; + *want = IRCD_TLS_WANT_READ; + return IO_BLOCKED; + } + if (sslerr == SSL_ERROR_WANT_WRITE + || (sslerr == SSL_ERROR_SYSCALL + && (orig_errno == EINTR || orig_errno == EAGAIN + || orig_errno == EWOULDBLOCK))) + { + /* Anything other than WANT_READ is reported as a write: a wrong "write" + * costs one loop pass, a wrong "read" would cost the whole deadline. */ + *want = IRCD_TLS_WANT_WRITE; + return IO_BLOCKED; + } + + /* Fatal. Report the most specific reason available; the caller drops the + * session. */ + if (vr != X509_V_OK) + tls_reason(reason, reasonlen, "%s", X509_verify_cert_error_string(vr)); + else if (queued) + tls_reason(reason, reasonlen, "%s", ERR_reason_error_string(queued)); + else if (sslerr == SSL_ERROR_ZERO_RETURN) + tls_reason(reason, reasonlen, "peer closed connection"); + else if (sslerr == SSL_ERROR_SYSCALL && orig_errno) + tls_reason(reason, reasonlen, "%s", strerror(orig_errno)); + else + tls_reason(reason, reasonlen, "handshake error"); + return IO_FAILURE; +} + +void tls_backend_drop(struct Client *cptr) +{ + SSL *tls = s_tls(&cli_socket(cptr)); + + if (tls) + { + s_tls(&cli_socket(cptr)) = NULL; + /* Do not SSL_shutdown() after a fatal error. */ + SSL_free(tls); } } -IOResult ircd_tls_recv(struct Client *cptr, char *buf, - unsigned int length, unsigned int *count_out) +/** Classify a non-WANT SSL error for the read/write paths, without tearing the + * session down (the core owns teardown via tls_io_fatal()/tls_backend_drop()). + * SYSCALL EINTR/EAGAIN is a normal block; ZERO_RETURN and everything else are + * fatal. */ +static IOResult ssl_io_result(SSL *tls, int err, int orig_errno) +{ + if (err == SSL_ERROR_SYSCALL && + (orig_errno == EINTR || orig_errno == EAGAIN || orig_errno == EWOULDBLOCK)) + return IO_BLOCKED; + if (err == SSL_ERROR_ZERO_RETURN) + { + if (SSL_shutdown(tls) == 0) + SSL_shutdown(tls); + } + return IO_FAILURE; +} + +IOResult tls_backend_read(struct Client *cptr, char *buf, unsigned int length, + unsigned int *count_out, enum ircd_tls_want *want) { SSL *tls; - int res, orig_errno; + int res, orig_errno, err; + + *count_out = 0; + *want = IRCD_TLS_WANT_NONE; tls = s_tls(&cli_socket(cptr)); if (!tls) return IO_FAILURE; + ERR_clear_error(); res = SSL_read(tls, buf, length); if (res > 0) { - *count_out = res; + *count_out = (unsigned int)res; return IO_SUCCESS; } orig_errno = errno; - *count_out = 0; - - return ssl_handle_error(cptr, tls, res, orig_errno); + err = SSL_get_error(tls, res); + if (err == SSL_ERROR_WANT_WRITE) + { + *want = IRCD_TLS_WANT_WRITE; + return IO_BLOCKED; + } + if (err == SSL_ERROR_WANT_READ) + { + *want = IRCD_TLS_WANT_READ; + return IO_BLOCKED; + } + return ssl_io_result(tls, err, orig_errno); } -IOResult ircd_tls_sendv(struct Client *cptr, struct MsgQ *buf, - unsigned int *count_in, - unsigned int *count_out) +IOResult tls_backend_write(struct Client *cptr, const char *buf, + unsigned int len, unsigned int *written, + enum ircd_tls_want *want) { - struct iovec iov[512]; SSL *tls; - struct Connection *con; - int ii, count, res, orig_errno; - int made_progress = 0; - IOResult io; + int res, orig_errno, err; + + *written = 0; + *want = IRCD_TLS_WANT_NONE; - con = cli_connect(cptr); - tls = s_tls(&con_socket(con)); + tls = s_tls(&cli_socket(cptr)); if (!tls) return IO_FAILURE; - *count_in = 0; - *count_out = 0; - if (con->con_rexmit) + + ERR_clear_error(); + res = SSL_write(tls, buf, (int)len); + if (res > 0) { - /* con_rexmit is a raw pointer into the head queued message left - * unfinished by a prior partial SSL_write. Drain it to completion (a - * short SSL_write does not mean the socket is full under - * SSL_MODE_ENABLE_PARTIAL_WRITE), then remove that exact message from the - * queue by identity with msgq_excise(). These bytes are deliberately NOT - * added to *count_out: msgq_delete() deletes in (partial-normal, prio, - * normal) order, so crediting a whole normal message here would instead - * delete a priority message that jumped ahead while we were blocked. */ - const char *rexmit_base = con->con_rexmit; - - while (con->con_rexmit) - { - ERR_clear_error(); - res = SSL_write(tls, con->con_rexmit, (int)con->con_rexmit_len); - if (res <= 0) { - orig_errno = errno; - io = ssl_handle_error(cptr, tls, res, orig_errno); - if (io == IO_FAILURE) - *count_out = 0; - return io; - } - if (res == (int)con->con_rexmit_len) { - con->con_rexmit_len = 0; - con->con_rexmit = NULL; - } else { - con->con_rexmit = (char *)con->con_rexmit + res; - con->con_rexmit_len -= (size_t)res; - } - } - msgq_excise(buf, rexmit_base); - made_progress = 1; - /* fall through to send more from the now-shorter queue */ + *written = (unsigned int)res; + return IO_SUCCESS; } - /* Process remaining messages in the queue. */ - count = msgq_mapiov(buf, iov, sizeof(iov) / sizeof(iov[0]), count_in); - for (ii = 0; ii < count; ++ii) + orig_errno = errno; + err = SSL_get_error(tls, res); + if (err == SSL_ERROR_WANT_READ) { - ERR_clear_error(); - res = SSL_write(tls, iov[ii].iov_base, iov[ii].iov_len); - if (res > 0) - { - *count_out += res; - if (res < (int)iov[ii].iov_len) { - con->con_rexmit = (char *)iov[ii].iov_base + res; - con->con_rexmit_len = iov[ii].iov_len - (size_t)res; - /* Finish this message or stop on real TLS block. These bytes are - * in mapiov order, so they are safe to credit to *count_out. */ - while (con->con_rexmit) - { - ERR_clear_error(); - res = SSL_write(tls, con->con_rexmit, (int)con->con_rexmit_len); - if (res <= 0) { - orig_errno = errno; - io = ssl_handle_error(cptr, tls, res, orig_errno); - if (io == IO_FAILURE) - *count_out = 0; - return io; - } - *count_out += (unsigned int)res; - if (res == (int)con->con_rexmit_len) { - con->con_rexmit_len = 0; - con->con_rexmit = NULL; - } else { - con->con_rexmit = (char *)con->con_rexmit + res; - con->con_rexmit_len -= (size_t)res; - } - } - } - continue; - } - - /* SSL_write failed before any bytes of this iov were accepted. */ - orig_errno = errno; - con->con_rexmit = iov[ii].iov_base; - con->con_rexmit_len = iov[ii].iov_len; - io = ssl_handle_error(cptr, tls, res, orig_errno); - if (io == IO_FAILURE) - *count_out = 0; - return io; + *want = IRCD_TLS_WANT_READ; + return IO_BLOCKED; } - - return (*count_out || made_progress) ? IO_SUCCESS : IO_BLOCKED; + if (err == SSL_ERROR_WANT_WRITE) + { + *want = IRCD_TLS_WANT_WRITE; + return IO_BLOCKED; + } + return ssl_io_result(tls, err, orig_errno); } + int ircd_tls_sha1_base64(const void *data, size_t len, char *out, size_t outlen) { unsigned char digest[SHA_DIGEST_LENGTH]; diff --git a/tests/README.md b/tests/README.md index 14df56e8..8a8dd580 100644 --- a/tests/README.md +++ b/tests/README.md @@ -42,6 +42,11 @@ uv run pytest -m nf_compat # A(prod)-B(NF=FALSE)-C topology # TLS suite only uv run pytest tls/ -v +# TLS suite against a specific TLS backend (default: openssl) +# The tls-hub/tls-leaf images are (re)built with the chosen backend. +TLS_BACKEND=gnutls uv run pytest tls/ -v +TLS_BACKEND=libtls uv run pytest tls/ -v + # NETWORK_FEATURES rolling-upgrade compat (downloads prod release on first build) uv run pytest pr_network_features_compat/ -v diff --git a/tests/docker/ircd-tls-hub.conf b/tests/docker/ircd-tls-hub.conf index ccd571a1..92ff9a6d 100644 --- a/tests/docker/ircd-tls-hub.conf +++ b/tests/docker/ircd-tls-hub.conf @@ -81,6 +81,19 @@ Connect { tls fingerprint = "0f0a727085a80a3f772e1994dfb332be3838f06015a6fc657c84d0e19117f61d"; }; +# Misbehaving TLS peer (tests/tls/test_tls_bogus_peer.py): a sidecar +# container on the test network the hub is told to CONNECT to. +Connect { + name = "bogus.test.net"; + host = "10.55.0.40"; + password = "testpass"; + class = "Server"; + hub; + autoconnect = no; + tls = yes; + tls fingerprint = "0f0a727085a80a3f772e1994dfb332be3838f06015a6fc657c84d0e19117f61d"; +}; + Connect { name = "tlspeer-ca.test.net"; host = "10.55.0.1"; diff --git a/tests/tls/bogus_peer.py b/tests/tls/bogus_peer.py new file mode 100644 index 00000000..25c83278 --- /dev/null +++ b/tests/tls/bogus_peer.py @@ -0,0 +1,565 @@ +"""Misbehaving TLS peers for edge-case testing of ircd's TLS handshake. + +Both halves drive OpenSSL through ``ssl.MemoryBIO`` instead of a socket, so +the test -- not the TLS library -- decides which bytes reach the wire and +when. That is what makes it possible to leave the server parked in exactly +one handshake state (waiting to read, waiting to write, mid-record, ...) and +observe what it does there. + +``BogusTLSClient`` connects *to* ircd (client or server TLS port). +``BogusTLSServer`` is the server half; ``SidecarBogusServer`` runs it in a +container on the docker test network (the host firewall may not let +containers reach the host) so ircd can be made to connect *out* to it with +``CONNECT bogus.test.net `` -- see the Connect block in +tests/docker/ircd-tls-hub.conf. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import socket +import ssl +import struct +import subprocess +import time +from dataclasses import dataclass, field + +from tls_certs import cert_path, key_path + + +# --------------------------------------------------------------------------- +# Wire helpers +# --------------------------------------------------------------------------- + + +async def _open_socket(host: str, port: int, *, rcvbuf: int | None = None): + """asyncio streams over a socket we configured before connecting.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + if rcvbuf is not None: + # Must be set before connect() so the advertised window is small too. + sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, rcvbuf) + sock.setblocking(False) + loop = asyncio.get_running_loop() + await loop.sock_connect(sock, (host, port)) + reader, writer = await asyncio.open_connection(sock=sock) + return reader, writer + + +async def wait_for_eof(reader: asyncio.StreamReader, timeout: float) -> tuple[bytes, float]: + """Read until EOF (or timeout). Returns (bytes received, seconds waited).""" + start = time.monotonic() + chunks = [] + try: + while True: + remaining = timeout - (time.monotonic() - start) + if remaining <= 0: + raise asyncio.TimeoutError + data = await asyncio.wait_for(reader.read(65536), remaining) + if not data: + break + chunks.append(data) + except asyncio.TimeoutError: + return b"".join(chunks), -1.0 + except (ConnectionResetError, BrokenPipeError): + pass + return b"".join(chunks), time.monotonic() - start + + +# --------------------------------------------------------------------------- +# Bogus client +# --------------------------------------------------------------------------- + + +@dataclass +class BogusTLSClient: + host: str + port: int + rcvbuf: int | None = None + cert: str | None = None # client certificate name from tests/docker/certs + reader: asyncio.StreamReader = field(init=False, default=None) + writer: asyncio.StreamWriter = field(init=False, default=None) + incoming: ssl.MemoryBIO = field(init=False, default=None) + outgoing: ssl.MemoryBIO = field(init=False, default=None) + tls: ssl.SSLObject = field(init=False, default=None) + + async def connect(self) -> None: + self.reader, self.writer = await _open_socket( + self.host, self.port, rcvbuf=self.rcvbuf + ) + + def start_tls(self) -> bytes: + """Create the client-side TLS state and return the ClientHello bytes + (not yet sent).""" + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + if self.cert: + ctx.load_cert_chain(cert_path(self.cert), key_path(self.cert)) + self.incoming = ssl.MemoryBIO() + self.outgoing = ssl.MemoryBIO() + self.tls = ctx.wrap_bio(self.incoming, self.outgoing, server_side=False) + return self.handshake_step() + + def handshake_step(self) -> bytes: + """Advance the handshake as far as possible; return bytes to send.""" + try: + self.tls.do_handshake() + except ssl.SSLWantReadError: + pass + return self.outgoing.read() + + def _pending(self) -> bool: + try: + self.tls.do_handshake() + return False + except ssl.SSLWantReadError: + return True + + async def send_raw(self, data: bytes) -> None: + self.writer.write(data) + await self.writer.drain() + + async def send_slowly(self, data: bytes, chunk: int, delay: float) -> None: + for i in range(0, len(data), chunk): + self.writer.write(data[i:i + chunk]) + await self.writer.drain() + await asyncio.sleep(delay) + + async def feed(self, timeout: float = 5.0) -> bytes: + """Read one chunk from the wire into the TLS engine; returns it.""" + data = await asyncio.wait_for(self.reader.read(65536), timeout) + if data: + self.incoming.write(data) + return data + + async def complete_handshake(self, timeout: float = 10.0, *, flush: bool = True) -> bytes: + """Run the handshake to completion. + + With flush=False the client's final flight (Finished) is left in + the outgoing BIO and returned instead of sent, so a test can + coalesce it with application data in a single segment. + """ + deadline = time.monotonic() + timeout + out = self.start_tls() if self.tls is None else self.handshake_step() + if out: + await self.send_raw(out) + while self._pending(): + if time.monotonic() > deadline: + raise asyncio.TimeoutError("TLS handshake did not complete") + data = await self.feed(timeout=max(0.1, deadline - time.monotonic())) + if not data: + raise ConnectionError("EOF during TLS handshake") + if self._pending(): + out = self.handshake_step() + if out: + await self.send_raw(out) + final = self.outgoing.read() # client Finished (TLS 1.3) + if flush and final: + await self.send_raw(final) + return b"" + return final + + def app_bytes(self, text: str) -> bytes: + """Encrypt `text` and return the record bytes without sending.""" + self.tls.write(text.encode()) + return self.outgoing.read() + + async def send_app(self, text: str) -> None: + await self.send_raw(self.app_bytes(text)) + + async def recv_app(self, timeout: float = 5.0) -> str: + while True: + try: + return self.tls.read(65536).decode(errors="replace") + except ssl.SSLWantReadError: + data = await self.feed(timeout) + if not data: + raise ConnectionError("EOF") + + async def close_rst(self) -> None: + """Abort with RST instead of FIN.""" + sock = self.writer.get_extra_info("socket") + sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)) + self.writer.close() + + async def close_fin(self) -> None: + """Half-close: FIN to the server, keep reading.""" + self.writer.write_eof() + + async def close(self) -> None: + if self.writer is not None: + self.writer.close() + try: + await self.writer.wait_closed() + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Bogus server (ircd connects out to it) +# --------------------------------------------------------------------------- + + +class BogusTLSServer: + """A TLS 'server' with selectable misbehaviour. + + modes: + silent accept, read everything, never send a byte + close accept, close immediately + garbage accept, send random bytes, keep reading + truncated_flight reply to ClientHello with the first `truncate` bytes + of the server flight, then go silent + complete full handshake with the given cert, then record the + decrypted application data ircd sends (PASS/SERVER) + slow_complete full handshake, but every server flight is dribbled + `chunk` bytes at a time with `chunk_delay` between + writes (after an optional `pre_delay`). With a slow + enough drip the handshake is still incomplete when + ircd's 5 s deadline fires -- exercising the deadline + teardown racing with in-flight handshake data. + slow_close dribble a partial server flight, then close mid-drip. + """ + + def __init__(self, mode: str, *, cert: str = "tlspeer", truncate: int = 200, + delay: float = 0.0, pre_delay: float = 0.0, + chunk: int = 0, chunk_delay: float = 0.0): + self.mode = mode + self.cert = cert + self.truncate = truncate + # Seconds to wait after accept before misbehaving. 0 hits the peer + # while ircd is still inside its connect-completion step; ~1 s makes + # sure ircd has parked in "waiting for the server flight" first. + self.delay = delay + # Latency knobs (slow_* modes): pre_delay before the first flight, then + # each flight written `chunk` bytes at a time with `chunk_delay` between + # writes (chunk=0 means write the whole flight at once). + self.pre_delay = pre_delay + self.chunk = chunk + self.chunk_delay = chunk_delay + self.server: asyncio.AbstractServer | None = None + self.port: int = 0 + self.accepted = asyncio.Event() + self.received_raw = bytearray() + self.app_lines: list[str] = [] + self.done = asyncio.Event() + self._closed = False + + async def start(self, host: str = "0.0.0.0") -> int: + self.server = await asyncio.start_server(self._handle, host, 0) + self.port = self.server.sockets[0].getsockname()[1] + return self.port + + async def stop(self) -> None: + if self.server is not None: + self.server.close() + try: + await self.server.wait_closed() + except Exception: + pass + self.server = None + + async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + self.accepted.set() + try: + if self.delay: + await asyncio.sleep(self.delay) + if self.mode == "close": + writer.close() + return + if self.mode == "garbage": + writer.write(os.urandom(512)) + await writer.drain() + await self._drain_until_eof(reader) + return + if self.mode == "silent": + await self._drain_until_eof(reader) + return + + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(cert_path(self.cert), key_path(self.cert)) + incoming, outgoing = ssl.MemoryBIO(), ssl.MemoryBIO() + tls = ctx.wrap_bio(incoming, outgoing, server_side=True) + + if self.mode == "truncated_flight": + # Feed the ClientHello, take the server flight, send a prefix. + while True: + data = await reader.read(65536) + if not data: + return + self.received_raw += data + incoming.write(data) + try: + tls.do_handshake() + except ssl.SSLWantReadError: + pass + flight = outgoing.read() + if flight: + writer.write(flight[: self.truncate]) + await writer.drain() + break + await self._drain_until_eof(reader) + return + + if self.mode in ("slow_complete", "slow_close"): + if self.pre_delay: + await asyncio.sleep(self.pre_delay) + # Drive the handshake, dribbling each outgoing flight so the + # peer's SSL_connect sees many small WANT_READ steps; a slow + # enough drip is still going when ircd's 5 s deadline fires. + while True: + try: + tls.do_handshake() + break + except ssl.SSLWantReadError: + out = outgoing.read() + if out: + if self.mode == "slow_close": + await self._write_slow(writer, out[: self.truncate]) + writer.close() + return + await self._write_slow(writer, out) + data = await reader.read(65536) + if not data: + return + self.received_raw += data + incoming.write(data) + out = outgoing.read() + if out: + await self._write_slow(writer, out) + # Handshake done -> behave like `complete`: decrypt app lines. + buf = "" + while True: + try: + chunk = tls.read(65536) + if not chunk: + return + buf += chunk.decode(errors="replace") + while "\n" in buf: + line, buf = buf.split("\n", 1) + self.app_lines.append(line.rstrip("\r")) + self.done.set() + except ssl.SSLWantReadError: + data = await reader.read(65536) + if not data: + return + incoming.write(data) + except ssl.SSLZeroReturnError: + return + + if self.mode == "complete": + # Full handshake, then decrypt whatever ircd sends. + while True: + try: + tls.do_handshake() + break + except ssl.SSLWantReadError: + out = outgoing.read() + if out: + writer.write(out) + await writer.drain() + data = await reader.read(65536) + if not data: + return + self.received_raw += data + incoming.write(data) + out = outgoing.read() + if out: + writer.write(out) + await writer.drain() + buf = "" + while True: + try: + chunk = tls.read(65536) + if not chunk: + return + buf += chunk.decode(errors="replace") + while "\n" in buf: + line, buf = buf.split("\n", 1) + self.app_lines.append(line.rstrip("\r")) + self.done.set() + except ssl.SSLWantReadError: + data = await reader.read(65536) + if not data: + return + incoming.write(data) + except ssl.SSLZeroReturnError: + return + except (ConnectionResetError, BrokenPipeError, asyncio.IncompleteReadError): + pass + finally: + self.done.set() + writer.close() + + async def _write_slow(self, writer: asyncio.StreamWriter, data: bytes) -> None: + """Write `data`, dribbling `self.chunk` bytes at a time with + `self.chunk_delay` between writes (whole write when chunk<=0).""" + if self.chunk and self.chunk > 0: + for i in range(0, len(data), self.chunk): + writer.write(data[i:i + self.chunk]) + await writer.drain() + if self.chunk_delay: + await asyncio.sleep(self.chunk_delay) + else: + writer.write(data) + await writer.drain() + + async def _drain_until_eof(self, reader: asyncio.StreamReader) -> None: + while True: + data = await reader.read(65536) + if not data: + return + self.received_raw += data + + +# --------------------------------------------------------------------------- +# Sidecar: the bogus server inside the docker test network +# --------------------------------------------------------------------------- + +SIDECAR_IMAGE = "python:3-alpine" +SIDECAR_NAME = "ircu-bogus-tls" +SIDECAR_IP = "10.55.0.40" # Connect { name = "bogus.test.net" } in ircd-tls-hub.conf +SIDECAR_PORT = 4500 +TESTS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _test_network() -> str: + out = subprocess.run( + ["docker", "network", "ls", "--filter", "name=ircu-test-net", "--format", "{{.Name}}"], + capture_output=True, text=True, timeout=30, + ) + names = [n for n in out.stdout.split() if n] + if not names: + raise RuntimeError("ircu-test-net docker network not found (is the TLS topology up?)") + return names[0] + + +class SidecarBogusServer: + """BogusTLSServer running in a container on the test network. + + The host firewall may drop container->host traffic, so the server runs + where the hub can reach it. Events are read back from the container's + stdout as JSON lines (see bogus_server_main.py). + """ + + def __init__(self, mode: str, *, truncate: int = 200, cert: str = "tlspeer", + delay: float = 0.0, pre_delay: float = 0.0, + chunk: int = 0, chunk_delay: float = 0.0): + self.mode = mode + self.truncate = truncate + self.cert = cert + self.delay = delay + self.pre_delay = pre_delay + self.chunk = chunk + self.chunk_delay = chunk_delay + self.port = SIDECAR_PORT + self.ip = SIDECAR_IP + self.events: list[dict] = [] + self._started = False + + async def start(self) -> int: + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, self._start_blocking) + self._started = True + await self.wait_event("listening", 20.0) + return self.port + + def _start_blocking(self) -> None: + subprocess.run(["docker", "rm", "-f", SIDECAR_NAME], capture_output=True, timeout=30) + net = _test_network() + cmd = [ + "docker", "run", "-d", "--name", SIDECAR_NAME, + "--network", net, "--ip", self.ip, + "-v", f"{TESTS_DIR}:/tests:ro", "-e", "PYTHONPATH=/tests", "-w", "/tests", + SIDECAR_IMAGE, "python", "-u", "tls/bogus_server_main.py", + "--mode", self.mode, "--port", str(self.port), + "--truncate", str(self.truncate), "--cert", self.cert, + "--delay", str(self.delay), + "--pre-delay", str(self.pre_delay), + "--chunk", str(self.chunk), "--chunk-delay", str(self.chunk_delay), + ] + r = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + if r.returncode != 0: + raise RuntimeError(f"sidecar start failed: {r.stderr.strip()}") + + def _poll_events(self) -> list[dict]: + r = subprocess.run(["docker", "logs", SIDECAR_NAME], capture_output=True, text=True, timeout=30) + events = [] + for line in r.stdout.splitlines(): + line = line.strip() + if line.startswith("{"): + try: + events.append(json.loads(line)) + except ValueError: + pass + return events + + async def wait_event(self, name: str, timeout: float) -> dict: + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while True: + self.events = await loop.run_in_executor(None, self._poll_events) + for ev in self.events: + if ev.get("event") == name: + return ev + if loop.time() > deadline: + raise asyncio.TimeoutError(f"sidecar: no {name!r} event; got {self.events[-5:]}") + await asyncio.sleep(0.3) + + @property + def received_raw(self) -> bytes: + data = b"" + for ev in self.events: + if ev.get("event") == "raw": + data += bytes.fromhex(ev["hex"]) + return data + + @property + def app_lines(self) -> list[str]: + return [ev["text"] for ev in self.events if ev.get("event") == "line"] + + async def stop(self) -> None: + if not self._started: + return + loop = asyncio.get_running_loop() + await loop.run_in_executor( + None, + lambda: subprocess.run(["docker", "rm", "-f", SIDECAR_NAME], capture_output=True, timeout=30), + ) + self._started = False + + +# --------------------------------------------------------------------------- +# Observers +# --------------------------------------------------------------------------- + + +def container_cpu_percent(container: str) -> float | None: + """One `docker stats` sample of the container's CPU usage, or None.""" + try: + out = subprocess.run( + ["docker", "stats", "--no-stream", "--format", "{{.CPUPerc}}", container], + capture_output=True, text=True, timeout=15, + ) + except (OSError, subprocess.SubprocessError): + return None + if out.returncode != 0: + return None + value = out.stdout.strip().rstrip("%") + try: + return float(value) + except ValueError: + return None + + +async def sample_cpu(container: str, seconds: float) -> list[float]: + """Sample CPU% repeatedly for `seconds` (each sample takes ~1-2 s).""" + loop = asyncio.get_running_loop() + deadline = loop.time() + seconds + samples: list[float] = [] + while loop.time() < deadline: + value = await loop.run_in_executor(None, container_cpu_percent, container) + if value is not None: + samples.append(value) + return samples diff --git a/tests/tls/bogus_server_main.py b/tests/tls/bogus_server_main.py new file mode 100644 index 00000000..e00a41fb --- /dev/null +++ b/tests/tls/bogus_server_main.py @@ -0,0 +1,83 @@ +"""Entry point for running BogusTLSServer inside a container. + +Prints one JSON object per line on stdout so the test on the host can +follow what happened through `docker logs`: + + {"event": "listening", "port": N} + {"event": "accepted"} + {"event": "raw", "hex": "..."} bytes received on the wire (silent / + garbage / truncated modes) + {"event": "line", "text": "..."} decrypted application-data line + (complete mode) + {"event": "done"} +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import subprocess +import sys + +from tls.bogus_peer import BogusTLSServer + + +def emit(**kw) -> None: + sys.stdout.write(json.dumps(kw) + "\n") + sys.stdout.flush() + + +async def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--mode", required=True) + ap.add_argument("--port", type=int, default=4500) + ap.add_argument("--truncate", type=int, default=200) + ap.add_argument("--cert", default="tlspeer") + ap.add_argument("--delay", type=float, default=0.0) + ap.add_argument("--pre-delay", type=float, default=0.0) + ap.add_argument("--chunk", type=int, default=0) + ap.add_argument("--chunk-delay", type=float, default=0.0) + args = ap.parse_args() + + # Each scenario replaces the container at the same address; announce the + # new MAC so the hub's neighbour cache does not point at the old one. + try: + ip = subprocess.run(["hostname", "-i"], capture_output=True, text=True, timeout=5).stdout.split()[0] + subprocess.run(["arping", "-c", "2", "-U", "-I", "eth0", ip], capture_output=True, timeout=10) + except Exception: + pass + + srv = BogusTLSServer(args.mode, cert=args.cert, truncate=args.truncate, + delay=args.delay, pre_delay=args.pre_delay, + chunk=args.chunk, chunk_delay=args.chunk_delay) + srv.server = await asyncio.start_server(srv._handle, "0.0.0.0", args.port) + srv.port = args.port + emit(event="listening", port=args.port) + + async def report() -> None: + raw_sent = 0 + lines_sent = 0 + accepted_sent = False + while True: + await asyncio.sleep(0.2) + if srv.accepted.is_set() and not accepted_sent: + accepted_sent = True + emit(event="accepted") + if len(srv.received_raw) > raw_sent: + emit(event="raw", hex=bytes(srv.received_raw[raw_sent:]).hex()) + raw_sent = len(srv.received_raw) + while lines_sent < len(srv.app_lines): + emit(event="line", text=srv.app_lines[lines_sent]) + lines_sent += 1 + if srv.done.is_set(): + emit(event="done") + return + + await report() + # Keep serving (ircd may reconnect) until the container is removed. + await asyncio.Event().wait() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/tls/keyupdate_peer.py b/tests/tls/keyupdate_peer.py new file mode 100644 index 00000000..017e8b3c --- /dev/null +++ b/tests/tls/keyupdate_peer.py @@ -0,0 +1,284 @@ +"""A TLS 1.3 client that can send a real KeyUpdate, driven through libssl by +ctypes. + +Python's ``ssl`` module exposes no ``key_update()`` and does not surface the +underlying ``SSL*``, so the misbehaving-peer harness (which uses +``ssl.MemoryBIO``) cannot produce the one thing the ircd's post-handshake +cross-direction machinery exists to handle: a peer-initiated KeyUpdate that +forces the server's ``SSL_read``/``SSL_write`` to block on the *opposite* +socket direction. + +This peer owns the ``SSL*`` (created over two memory BIOs), so it can: + * complete the handshake and exchange application data, + * call ``SSL_key_update(ssl, SSL_KEY_UPDATE_UPDATE_REQUESTED)`` and emit the + resulting encrypted KeyUpdate record, + * put that record on the raw socket whole or truncated, with full control + over read timing and the receive-buffer size. + +Run this module directly to self-test the ctypes machinery against a local +Python TLS server (no ircd needed): ``python tls/keyupdate_peer.py`` +""" + +from __future__ import annotations + +import ctypes +import ctypes.util +import socket + +# --------------------------------------------------------------------------- +# libssl / libcrypto bindings +# --------------------------------------------------------------------------- + +_ssl = ctypes.CDLL(ctypes.util.find_library("ssl") or "libssl.so.3") +_crypto = ctypes.CDLL(ctypes.util.find_library("crypto") or "libcrypto.so.3") + +# OpenSSL constants +SSL_ERROR_NONE = 0 +SSL_ERROR_SSL = 1 +SSL_ERROR_WANT_READ = 2 +SSL_ERROR_WANT_WRITE = 3 +SSL_VERIFY_NONE = 0 +SSL_KEY_UPDATE_NOT_REQUESTED = 0 +SSL_KEY_UPDATE_REQUESTED = 1 +BIO_CTRL_PENDING = 10 + +_p = ctypes.c_void_p +_i = ctypes.c_int + + +def _sig(fn, restype, *argtypes): + fn.restype = restype + fn.argtypes = list(argtypes) + return fn + + +_sig(_ssl.TLS_client_method, _p) +_sig(_ssl.SSL_CTX_new, _p, _p) +_sig(_ssl.SSL_CTX_free, None, _p) +_sig(_ssl.SSL_CTX_set_verify, None, _p, _i, _p) +_sig(_ssl.SSL_new, _p, _p) +_sig(_ssl.SSL_free, None, _p) +_sig(_ssl.SSL_set_connect_state, None, _p) +_sig(_ssl.SSL_set_bio, None, _p, _p, _p) +_sig(_ssl.SSL_do_handshake, _i, _p) +_sig(_ssl.SSL_get_error, _i, _p, _i) +_sig(_ssl.SSL_read, _i, _p, _p, _i) +_sig(_ssl.SSL_write, _i, _p, _p, _i) +_sig(_ssl.SSL_key_update, _i, _p, _i) +_sig(_ssl.SSL_is_init_finished, _i, _p) +_sig(_crypto.BIO_new, _p, _p) +_sig(_crypto.BIO_s_mem, _p) +_sig(_crypto.BIO_read, _i, _p, _p, _i) +_sig(_crypto.BIO_write, _i, _p, _p, _i) +_sig(_crypto.BIO_ctrl, ctypes.c_long, _p, _i, ctypes.c_long, _p) + + +def _bio_pending(bio) -> int: + return int(_crypto.BIO_ctrl(bio, BIO_CTRL_PENDING, 0, None)) + + +class KeyUpdatePeer: + """A minimal TLS 1.3 client with KeyUpdate control, over a raw socket.""" + + def __init__(self, host: str, port: int, *, rcvbuf: int | None = None): + self.host = host + self.port = port + self.rcvbuf = rcvbuf + self.sock: socket.socket | None = None + self.ctx = None + self.ssl = None + self.rbio = None # data coming IN from the wire -> SSL + self.wbio = None # data going OUT from SSL -> the wire + + # -- lifecycle ---------------------------------------------------------- + + def connect(self, timeout: float = 10.0) -> None: + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + if self.rcvbuf is not None: + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, self.rcvbuf) + self.sock.settimeout(timeout) + self.sock.connect((self.host, self.port)) + + self.ctx = _ssl.SSL_CTX_new(_ssl.TLS_client_method()) + if not self.ctx: + raise RuntimeError("SSL_CTX_new failed") + _ssl.SSL_CTX_set_verify(self.ctx, SSL_VERIFY_NONE, None) + self.ssl = _ssl.SSL_new(self.ctx) + if not self.ssl: + raise RuntimeError("SSL_new failed") + self.rbio = _crypto.BIO_new(_crypto.BIO_s_mem()) + self.wbio = _crypto.BIO_new(_crypto.BIO_s_mem()) + _ssl.SSL_set_bio(self.ssl, self.rbio, self.wbio) # SSL takes ownership + _ssl.SSL_set_connect_state(self.ssl) + + def close(self) -> None: + if self.ssl: + _ssl.SSL_free(self.ssl) # frees the attached BIOs too + self.ssl = None + if self.ctx: + _ssl.SSL_CTX_free(self.ctx) + self.ctx = None + if self.sock: + try: + self.sock.close() + except OSError: + pass + self.sock = None + + # -- raw wire / BIO plumbing ------------------------------------------- + + def _flush_out(self) -> bytes: + """Drain SSL's outgoing BIO and send it on the socket; return the bytes.""" + out = self._take_out() + if out: + self.sock.sendall(out) + return out + + def _take_out(self) -> bytes: + """Drain SSL's outgoing BIO without sending (caller controls the wire).""" + chunks = [] + while True: + n = _bio_pending(self.wbio) + if n <= 0: + break + buf = ctypes.create_string_buffer(n) + got = _crypto.BIO_read(self.wbio, buf, n) + if got <= 0: + break + chunks.append(buf.raw[:got]) + return b"".join(chunks) + + def _feed_in(self, timeout: float | None = None) -> int: + """Read one chunk from the socket into SSL's incoming BIO.""" + if timeout is not None: + self.sock.settimeout(timeout) + data = self.sock.recv(65536) + if not data: + return 0 + _crypto.BIO_write(self.rbio, data, len(data)) + return len(data) + + # -- handshake / app data ---------------------------------------------- + + def handshake(self, timeout: float = 10.0) -> None: + while True: + r = _ssl.SSL_do_handshake(self.ssl) + self._flush_out() + if r == 1: + return + err = _ssl.SSL_get_error(self.ssl, r) + if err == SSL_ERROR_WANT_READ: + if self._feed_in(timeout) == 0: + raise ConnectionError("EOF during handshake") + elif err == SSL_ERROR_WANT_WRITE: + continue + else: + raise RuntimeError(f"handshake failed: SSL_get_error={err}") + + def write_app(self, data: bytes) -> None: + """Encrypt and send application data.""" + buf = ctypes.create_string_buffer(data, len(data)) + n = _ssl.SSL_write(self.ssl, buf, len(data)) + if n <= 0: + raise RuntimeError(f"SSL_write failed: {_ssl.SSL_get_error(self.ssl, n)}") + self._flush_out() + + def read_app(self, timeout: float = 5.0) -> bytes: + """Read and decrypt available application data (may block up to timeout).""" + out = ctypes.create_string_buffer(65536) + while True: + n = _ssl.SSL_read(self.ssl, out, 65536) + if n > 0: + return out.raw[:n] + err = _ssl.SSL_get_error(self.ssl, n) + if err == SSL_ERROR_WANT_READ: + if self._feed_in(timeout) == 0: + return b"" + else: + return b"" + + # -- the point of this class: KeyUpdate -------------------------------- + + def key_update_record(self, requested: bool = True) -> bytes: + """Return the encrypted KeyUpdate record bytes WITHOUT sending them. + + SSL_key_update() only arms the update; the record is produced on the + next SSL_write, so we do a zero-length-ish write and harvest the BIO. + """ + t = SSL_KEY_UPDATE_REQUESTED if requested else SSL_KEY_UPDATE_NOT_REQUESTED + if _ssl.SSL_key_update(self.ssl, t) != 1: + raise RuntimeError("SSL_key_update failed") + # A 1-byte write flushes the pending KeyUpdate first, then the app byte. + # We only want the KeyUpdate record, so write nothing schedulable: force + # the update out via SSL_do_handshake (valid post-handshake for pending + # key updates in OpenSSL 3), then harvest. + _ssl.SSL_do_handshake(self.ssl) + rec = self._take_out() + return rec + + def send_raw(self, data: bytes) -> None: + self.sock.sendall(data) + + +# --------------------------------------------------------------------------- +# Local self-test (no ircd): peer <-> a Python TLS echo server on 127.0.0.1 +# --------------------------------------------------------------------------- + +def _selftest() -> None: + import ssl as pyssl + import threading + import tls_certs + + ctx = pyssl.SSLContext(pyssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(tls_certs.cert_path("hub"), tls_certs.key_path("hub")) + srv = socket.socket() + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind(("127.0.0.1", 0)) + srv.listen(1) + port = srv.getsockname()[1] + result = {} + + def serve(): + conn, _ = srv.accept() + tconn = ctx.wrap_socket(conn, server_side=True) + try: + data = tconn.recv(4096) + tconn.sendall(b"echo:" + data) + # Read again — this forces the server to process the peer's KeyUpdate. + more = tconn.recv(4096) + result["after_keyupdate"] = more + tconn.sendall(b"post:" + more) + finally: + try: + tconn.close() + except OSError: + pass + + th = threading.Thread(target=serve, daemon=True) + th.start() + + peer = KeyUpdatePeer("127.0.0.1", port) + peer.connect() + peer.handshake() + assert _ssl.SSL_is_init_finished(peer.ssl) == 1, "handshake not finished" + peer.write_app(b"hello") + got = peer.read_app() + assert got == b"echo:hello", got + + rec = peer.key_update_record(requested=True) + assert rec and rec[0] == 0x17, f"expected a TLS1.3 app-data-wrapped record, got {rec[:8]!r}" + peer.send_raw(rec) # send the KeyUpdate whole + peer.write_app(b"world") # app data under the new key + got2 = peer.read_app() + assert got2 == b"post:world", got2 + peer.close() + th.join(timeout=5) + print("keyupdate_peer self-test OK: handshake + app data + KeyUpdate + rekeyed app data") + + +if __name__ == "__main__": + import os + import sys + + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + _selftest() diff --git a/tests/tls/test_tls_bogus_peer.py b/tests/tls/test_tls_bogus_peer.py new file mode 100644 index 00000000..cba0bf1c --- /dev/null +++ b/tests/tls/test_tls_bogus_peer.py @@ -0,0 +1,668 @@ +"""Misbehaving TLS peers against ircd's inbound and outbound handshake paths. + +Each scenario parks ircd in one handshake state (waiting for the peer, +blocked on a write, mid-record, garbage on the wire, RST/FIN mid-handshake, +peer that never reads) and checks three things: + + * the deadline: the connection is torn down at TLS_HANDSHAKE_TIMEOUT (5 s) + or promptly on a hard failure, with nothing written before the close; + * no CPU spin: `docker stats` on the hub stays far below 100% while peers + are stalled (the original inbound bug spun on a level-triggered writable + event for the whole handshake); + * liveness: a healthy client keeps getting PONGs while peers are stalled. + +Outbound scenarios use the hub's `Connect { name = "bogus.test.net"; +host = "10.55.0.40"; }` block: a BogusTLSServer runs in a sidecar container +on the test network and an oper issues `CONNECT bogus.test.net `, so +the hub runs its own client-side handshake against a server we control +byte by byte. + +Set BOGUS_TLS_HOST / BOGUS_TLS_PORT / BOGUS_TLS_SERVER_PORT to run the +inbound scenarios against a real ircd instead of the docker hub (CPU and +notice checks are skipped there). +""" + +from __future__ import annotations + +import asyncio +import os +import re +import time + +import pytest +import pytest_asyncio + +from irc_client import IRCClient +from tls.bogus_peer import BogusTLSClient, SidecarBogusServer, sample_cpu, wait_for_eof +from tls.helpers import oper_up + +pytestmark = [pytest.mark.tls, pytest.mark.asyncio] + +HUB_CONTAINER = "ircu-tls-hub" +HANDSHAKE_TIMEOUT = 5.0 +# Deadline window: timer fires at 5 s, plus docker/scheduling slack. +CLOSE_MIN, CLOSE_MAX = 3.5, 10.0 +CPU_SPIN_THRESHOLD = 50.0 # a single-threaded spin shows as ~100% + +EXTERNAL = os.environ.get("BOGUS_TLS_HOST") + + +def _target(hub: dict) -> dict: + if EXTERNAL: + return { + "host": EXTERNAL, + "tls_port": int(os.environ["BOGUS_TLS_PORT"]), + "server_port": int(os.environ["BOGUS_TLS_SERVER_PORT"]), + "external": True, + } + return {"host": hub["host"], "tls_port": hub["tls_port"], + "server_port": hub["server_port"], "external": False} + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + + +async def _oper(hub: dict, nick: str) -> IRCClient: + c = IRCClient() + await c.connect(hub["host"], hub["port"]) + await c.register(nick, "oper", "Bogus peer oper") + msg = await oper_up(c) + assert msg.command == "381", msg + await c.send(f"MODE {nick} +s +65535") + await asyncio.sleep(0.2) + return c + + +async def _notices(oper: IRCClient, pattern: str, seconds: float) -> list[str]: + """Collect server notices matching `pattern` for `seconds`.""" + rx = re.compile(pattern) + found = [] + loop = asyncio.get_running_loop() + deadline = loop.time() + seconds + while True: + remaining = deadline - loop.time() + if remaining <= 0: + return found + try: + msg = await oper.recv(timeout=remaining) + except asyncio.TimeoutError: + return found + if msg.command == "NOTICE" and rx.search(msg.params[-1]): + found.append(msg.params[-1]) + + +async def _wait_notice(oper: IRCClient, pattern: str, timeout: float) -> str: + rx = re.compile(pattern) + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + seen = [] + while True: + remaining = deadline - loop.time() + if remaining <= 0: + raise AssertionError(f"no notice matching {pattern!r} within {timeout}s; saw {seen[-5:]}") + msg = await oper.recv(timeout=remaining) + if msg.command == "NOTICE": + seen.append(msg.params[-1]) + if rx.search(msg.params[-1]): + return msg.params[-1] + + +async def _ping_rtt(client: IRCClient, token: str, timeout: float = 2.0) -> float: + start = time.monotonic() + await client.send(f"PING :{token}") + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while True: + remaining = deadline - loop.time() + if remaining <= 0: + raise AssertionError("healthy client got no PONG while peers stalled") + msg = await client.recv(timeout=remaining) + if msg.command == "PONG" and msg.params[-1] == token: + return time.monotonic() - start + + +async def _recv_until(peer: BogusTLSClient, needle: str, timeout: float) -> str: + """Read decrypted data until `needle` appears or `timeout` elapses.""" + got = "" + answered = 0 + deadline = time.monotonic() + timeout + while needle not in got: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + try: + got += await peer.recv_app(timeout=min(remaining, 5.0)) + except asyncio.TimeoutError: + continue + except ConnectionError: + break + # Registration requires answering the nospoof PING. + for m in re.finditer(r"^PING :(\S+)\r?$", got, re.M): + if m.end() > answered: + await peer.send_app(f"PONG :{m.group(1)}\r\n") + answered = m.end() + return got + + +async def _cpu_or_none(target: dict, seconds: float) -> list[float]: + if target["external"]: + await asyncio.sleep(seconds) + return [] + return await sample_cpu(HUB_CONTAINER, seconds) + + +def _assert_no_spin(samples: list[float], what: str) -> None: + if not samples: + return + assert max(samples) < CPU_SPIN_THRESHOLD, ( + f"hub CPU spun while {what}: samples={samples}" + ) + + +def _assert_closed_on_deadline(elapsed: float, what: str) -> None: + assert elapsed >= 0, f"{what}: still open after {CLOSE_MAX}s" + assert CLOSE_MIN <= elapsed <= CLOSE_MAX, ( + f"{what}: closed after {elapsed:.1f}s, expected ~{HANDSHAKE_TIMEOUT}s" + ) + + +@pytest_asyncio.fixture +async def healthy(ircd_tls_network): + """A registered plaintext client on the hub used as a liveness probe.""" + hub = ircd_tls_network["hub"] + if EXTERNAL: + yield None + return + c = IRCClient() + await c.connect(hub["host"], hub["port"]) + await c.register("bogushealthy", "probe", "liveness probe") + yield c + try: + await c.disconnect() + except Exception: + pass + + +# --------------------------------------------------------------------------- +# inbound: bogus clients +# --------------------------------------------------------------------------- + + +async def test_silent_peers_time_out_without_spin(ircd_tls_network, healthy): + """Several peers that connect and send nothing: each is closed at the + deadline with no bytes written, the hub does not spin, and a healthy + client stays responsive meanwhile.""" + hub = ircd_tls_network["hub"] + tgt = _target(hub) + oper = None if tgt["external"] else await _oper(hub, "bogusop1") + peers = [BogusTLSClient(tgt["host"], tgt["tls_port"]) for _ in range(4)] + peers += [BogusTLSClient(tgt["host"], tgt["server_port"]) for _ in range(2)] + start = time.monotonic() + try: + for p in peers: + await p.connect() + samples = await _cpu_or_none(tgt, 3.0) + if healthy: + rtt = await _ping_rtt(healthy, "silent") + assert rtt < 1.0, f"PONG took {rtt:.2f}s during stall" + results = await asyncio.gather(*(wait_for_eof(p.reader, CLOSE_MAX) for p in peers)) + for (data, waited), p in zip(results, peers): + assert data == b"", f"server wrote {data[:40]!r} to a silent peer" + _assert_closed_on_deadline( + (time.monotonic() - start) if waited >= 0 else -1, f"silent peer on :{p.port}" + ) + _assert_no_spin(samples, "6 peers sat silent in the handshake") + if oper: + notes = await _notices(oper, r"TLS negotiation failed from unknown server.*timed out", 2.0) + assert len(notes) >= 2, notes # the two server-port peers + finally: + for p in peers: + await p.close() + if oper: + await oper.disconnect() + + +async def test_stall_after_clienthello_times_out_without_spin(ircd_tls_network, healthy): + """ClientHello sent, server flight received, peer never continues.""" + hub = ircd_tls_network["hub"] + tgt = _target(hub) + peer = BogusTLSClient(tgt["host"], tgt["tls_port"]) + try: + await peer.connect() + hello = peer.start_tls() + start = time.monotonic() + await peer.send_raw(hello) + flight = await peer.feed(timeout=5.0) + assert flight[:1] == b"\x16", f"expected a TLS handshake record, got {flight[:8]!r}" + samples = await _cpu_or_none(tgt, 3.0) + if healthy: + assert await _ping_rtt(healthy, "stall") < 1.0 + data, waited = await wait_for_eof(peer.reader, CLOSE_MAX) + _assert_closed_on_deadline((time.monotonic() - start) if waited >= 0 else -1, "stalled after ClientHello") + # Nothing but TLS records: no plaintext ERROR into the handshake stream. + assert not data or data[:1] in (b"\x15", b"\x16", b"\x17"), data[:40] + _assert_no_spin(samples, "peer stalled after ClientHello") + finally: + await peer.close() + + +async def test_slowloris_clienthello_is_cut_at_deadline(ircd_tls_network, healthy): + """A ClientHello dribbled one byte at a time keeps generating read + events but must not extend the deadline (and must not spin).""" + hub = ircd_tls_network["hub"] + tgt = _target(hub) + peer = BogusTLSClient(tgt["host"], tgt["tls_port"]) + try: + await peer.connect() + hello = peer.start_tls() + start = time.monotonic() + dribble = asyncio.create_task(peer.send_slowly(hello, 1, 0.15)) + samples = await _cpu_or_none(tgt, 3.0) + data, waited = await wait_for_eof(peer.reader, CLOSE_MAX) + dribble.cancel() + _assert_closed_on_deadline((time.monotonic() - start) if waited >= 0 else -1, "slowloris ClientHello") + assert data == b"" + _assert_no_spin(samples, "ClientHello dribbled 1 byte at a time") + finally: + await peer.close() + + +async def test_slow_but_complete_handshake_registers(ircd_tls_network): + """Control: a slow peer that does finish inside the deadline registers.""" + hub = ircd_tls_network["hub"] + tgt = _target(hub) + peer = BogusTLSClient(tgt["host"], tgt["tls_port"]) + try: + await peer.connect() + hello = peer.start_tls() + await peer.send_slowly(hello, 48, 0.1) # ~0.5-1 s + await peer.complete_handshake(timeout=8.0) + await peer.send_app("NICK bogusslow\r\nUSER slow 0 * :slow\r\n") + got = await _recv_until(peer, " 001 ", 15.0) + assert " 001 " in got, got[-300:] + finally: + await peer.close() + + +async def test_finished_coalesced_with_data_registers(ircd_tls_network): + """The client's Finished and its first application record arrive in + one segment. After the handshake completes the server must still pick + up the application data that is already queued (whether it sits in the + kernel or was pulled into the TLS library's buffer).""" + hub = ircd_tls_network["hub"] + tgt = _target(hub) + peer = BogusTLSClient(tgt["host"], tgt["tls_port"]) + try: + await peer.connect() + finished = await peer.complete_handshake(timeout=8.0, flush=False) + assert finished, "expected an unsent client Finished flight" + await peer.send_raw(finished + peer.app_bytes("NICK boguscoal\r\nUSER coal 0 * :coalesced\r\n")) + got = await _recv_until(peer, " 001 ", 15.0) + assert " 001 " in got, got[-300:] + finally: + await peer.close() + + +async def test_garbage_after_clienthello_closes_promptly(ircd_tls_network, healthy): + hub = ircd_tls_network["hub"] + tgt = _target(hub) + peer = BogusTLSClient(tgt["host"], tgt["tls_port"]) + try: + await peer.connect() + hello = peer.start_tls() + await peer.send_raw(hello) + await peer.feed(timeout=5.0) # server flight + start = time.monotonic() + await peer.send_raw(os.urandom(2048)) + data, waited = await wait_for_eof(peer.reader, CLOSE_MAX) + assert waited >= 0, "server kept a garbage-fed handshake open" + assert time.monotonic() - start < 3.0, "garbage should fail the handshake immediately" + assert not data or data[:1] in (b"\x15", b"\x16", b"\x17"), data[:40] + if healthy: + assert await _ping_rtt(healthy, "garbage") < 1.0 + finally: + await peer.close() + + +@pytest.mark.parametrize("how", ["rst", "fin"]) +async def test_abort_mid_handshake_then_normal_client_works(ircd_tls_network, how): + """RST or FIN right after ClientHello must be handled cleanly; a normal + TLS registration afterwards proves nothing was left wedged.""" + hub = ircd_tls_network["hub"] + tgt = _target(hub) + peer = BogusTLSClient(tgt["host"], tgt["tls_port"]) + await peer.connect() + hello = peer.start_tls() + await peer.send_raw(hello) + await peer.feed(timeout=5.0) + if how == "rst": + await peer.close_rst() + else: + await peer.close_fin() + data, waited = await wait_for_eof(peer.reader, 6.0) + assert waited >= 0, "server did not close after peer FIN mid-handshake" + assert waited < 3.0, f"FIN mid-handshake took {waited:.1f}s to close" + await peer.close() + await asyncio.sleep(0.3) + + ok = IRCClient() + await ok.connect_tls(tgt["host"], tgt["tls_port"]) + try: + msgs = await ok.register(f"bogusok{how}", "ok", "after abort") + assert any(m.command == "001" for m in msgs) + finally: + await ok.disconnect() + + +async def test_peer_that_never_reads_is_cut_at_deadline(ircd_tls_network, healthy): + """Tiny receive window, ClientHello sent, then the peer never reads: the + server's flight may block on write (WANT_WRITE); it must wait on the + write side without spinning and still abort at the deadline.""" + hub = ircd_tls_network["hub"] + tgt = _target(hub) + peer = BogusTLSClient(tgt["host"], tgt["tls_port"], rcvbuf=1024) + try: + await peer.connect() + hello = peer.start_tls() + start = time.monotonic() + await peer.send_raw(hello) + samples = await _cpu_or_none(tgt, 3.5) + if healthy: + assert await _ping_rtt(healthy, "noread") < 1.0 + await asyncio.sleep(max(0.0, HANDSHAKE_TIMEOUT + 1.5 - (time.monotonic() - start))) + # Only now start reading: whatever was queued, then EOF. + data, waited = await wait_for_eof(peer.reader, 4.0) + assert waited >= 0, "server kept a never-reading peer open past the deadline" + assert data[:1] == b"\x16", f"expected the server flight, got {data[:8]!r}" + _assert_no_spin(samples, "peer never read the server flight") + finally: + await peer.close() + + +async def test_flood_without_reading_after_handshake(ircd_tls_network, healthy): + """Completed handshake, then the peer floods commands and never reads + the replies: the data-path write side must back off, kill the client + (sendq or flood limit) and never spin.""" + hub = ircd_tls_network["hub"] + tgt = _target(hub) + peer = BogusTLSClient(tgt["host"], tgt["tls_port"], rcvbuf=2048) + try: + await peer.connect() + await peer.complete_handshake() + await peer.send_app("NICK bogusflood\r\nUSER flood 0 * :flood\r\n") + start = time.monotonic() + # Ask for lots of output without ever reading it. + try: + for _ in range(400): + await peer.send_app("MOTD\r\nLUSERS\r\nVERSION\r\nADMIN\r\n") + except (ConnectionResetError, BrokenPipeError, OSError): + pass + samples = await _cpu_or_none(tgt, 3.0) + if healthy: + assert await _ping_rtt(healthy, "flood") < 1.0 + data, waited = await wait_for_eof(peer.reader, 60.0) + assert waited >= 0, "flooding peer was never disconnected" + _assert_no_spin(samples, "peer flooded without reading") + finally: + await peer.close() + + +# --------------------------------------------------------------------------- +# outbound: bogus servers the hub connects to +# --------------------------------------------------------------------------- + +PEER_NAME = "bogus.test.net" + + +@pytest_asyncio.fixture +async def link_oper(ircd_tls_network): + if EXTERNAL: + pytest.skip("outbound scenarios need the docker hub") + hub = ircd_tls_network["hub"] + oper = await _oper(hub, "boguslink") + yield oper + try: + await oper.disconnect() + except Exception: + pass + + +async def _connect_out(oper: IRCClient, port: int) -> None: + await oper.send(f"CONNECT {PEER_NAME} {port}") + + +async def test_outbound_silent_server_times_out(ircd_tls_network, link_oper): + srv = SidecarBogusServer("silent") + port = await srv.start() + try: + await _connect_out(link_oper, port) + await srv.wait_event("accepted", 10.0) + start = time.monotonic() + samples = await sample_cpu(HUB_CONTAINER, 3.0) + note = await _wait_notice(link_oper, rf"TLS negotiation failed to {PEER_NAME}.*timed out", CLOSE_MAX) + elapsed = time.monotonic() - start + assert elapsed <= CLOSE_MAX, f"outbound timeout took {elapsed:.1f}s" + # The hub did start the handshake: a TLS handshake record arrived. + await srv.wait_event("raw", 3.0) + assert srv.received_raw[:1] == b"\x16", srv.received_raw[:8] + _assert_no_spin(samples, "outbound peer stayed silent") + assert "timed out" in note + finally: + await srv.stop() + + +async def test_outbound_truncated_server_flight_times_out(ircd_tls_network, link_oper): + srv = SidecarBogusServer("truncated_flight", truncate=200) + port = await srv.start() + try: + await _connect_out(link_oper, port) + await srv.wait_event("accepted", 10.0) + start = time.monotonic() + samples = await sample_cpu(HUB_CONTAINER, 3.0) + note = await _wait_notice(link_oper, rf"TLS negotiation failed to {PEER_NAME}", CLOSE_MAX) + assert "timed out" in note, note + assert time.monotonic() - start <= CLOSE_MAX + _assert_no_spin(samples, "outbound server sent a truncated flight") + finally: + await srv.stop() + + +# delay=0: the failure is already on the wire when the hub's connect step +# runs its first negotiate (detected in completed_connection()). +# delay=1: the hub has parked waiting for the server flight and the +# failure arrives as a later read event (detected in the ET_READ arm). +# Both must be reported to opers immediately, never via the 5 s deadline. +@pytest.mark.parametrize("delay", [0.0, 1.0], ids=["during-connect", "after-connect"]) +async def test_outbound_garbage_server_fails_fast(ircd_tls_network, link_oper, delay): + srv = SidecarBogusServer("garbage", delay=delay) + port = await srv.start() + try: + await _connect_out(link_oper, port) + await srv.wait_event("accepted", 10.0) + note = await _wait_notice(link_oper, rf"TLS negotiation failed to {PEER_NAME}", delay + 10.0) + # The 5 s handshake deadline reports "...: TLS handshake timed out"; a + # prompt detection reports the backend's read/handshake error instead. + # Asserting on the text (not on arrival timing) proves prompt detection + # without racing docker load. The window sits well above the 5 s + # deadline so a deadline-path regression is *received* and fails here, + # rather than surfacing as an opaque recv timeout. + assert "timed out" not in note, note + finally: + await srv.stop() + + +@pytest.mark.parametrize("delay", [0.0, 1.0], ids=["during-connect", "after-connect"]) +async def test_outbound_server_closes(ircd_tls_network, link_oper, delay): + srv = SidecarBogusServer("close", delay=delay) + port = await srv.start() + try: + await _connect_out(link_oper, port) + await srv.wait_event("accepted", 10.0) + # Depending on timing the EOF is seen by the TLS layer (unexpected + # eof), by the connect step, or as a socket reset on a later event; + # every variant must reach the oper who issued the CONNECT. + note = await _wait_notice( + link_oper, + rf"(TLS negotiation failed to {PEER_NAME}|Connection failed to {PEER_NAME}" + rf"|Link with {PEER_NAME} canceled)", + delay + 10.0, + ) + # Same invariant as the garbage case: the failure must be detected + # promptly, never left to the 5 s handshake deadline (whose notice + # always carries "TLS handshake timed out"). The text — not arrival + # timing — is the load-independent proof. + assert "timed out" not in note, note + finally: + await srv.stop() + + +async def test_outbound_full_handshake_delivers_pass_and_server(ircd_tls_network, link_oper): + """Control: against a well-behaved foreign TLS stack the hub completes + the handshake and sends PASS + SERVER over the encrypted link.""" + srv = SidecarBogusServer("complete", cert="tlspeer") + port = await srv.start() + try: + await _connect_out(link_oper, port) + await srv.wait_event("line", 10.0) + await asyncio.sleep(0.5) + await srv.wait_event("line", 1.0) + lines = srv.app_lines + assert any(l.startswith("PASS ") for l in lines), lines + assert any(l.startswith("SERVER tls-hub.test.net ") for l in lines), lines + finally: + await srv.stop() + # The hub drops the link once our server goes away. + await _notices(link_oper, r".", 1.0) + + +# --------------------------------------------------------------------------- +# outbound: latency / slow-handshake edge cases (server-to-server) +# +# These stress the hub's OUTBOUND TLS connect state machine under realistic +# latency: dribbled handshake flights, flights still arriving when the 5 s +# handshake deadline fires, and mid-handshake closes. The oracle for every +# case is threefold -- the hub must (1) not spin a core, (2) keep answering a +# healthy control client's PINGs throughout (proving it is neither hung nor +# crashed), and (3) reach a definite outcome (link up, or a prompt failure +# notice), never leave a completed handshake to the deadline. +# +# NOTE: the docker harness runs the epoll engine, so a purely kqueue-ordering +# fault cannot surface here; these cover the engine-independent behaviour of +# the handshake / deadline / teardown paths. +# --------------------------------------------------------------------------- + + +async def _keep_alive(client: IRCClient, seconds: float, gap: float = 0.5) -> int: + """PING `client` every `gap` seconds for `seconds`; each must PONG. + Returns the number of successful round-trips; raises if one is missed.""" + n = 0 + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + await _ping_rtt(client, f"alive{n}", timeout=3.0) + n += 1 + await asyncio.sleep(gap) + return n + + +async def test_outbound_slow_handshake_completes(ircd_tls_network, link_oper, healthy): + """A peer that dribbles every handshake flight still links; the hub does + the many small partial reads without spinning and stays responsive.""" + srv = SidecarBogusServer("slow_complete", cert="tlspeer", chunk=64, chunk_delay=0.05) + port = await srv.start() + try: + await _connect_out(link_oper, port) + await srv.wait_event("accepted", 10.0) + cpu = asyncio.create_task(sample_cpu(HUB_CONTAINER, 4.0)) + await _keep_alive(healthy, 4.0) + _assert_no_spin(await cpu, "peer dribbled the handshake") + # The handshake completed: the hub sent its PASS/SERVER over the link. + await srv.wait_event("line", 10.0) + assert any(l.startswith("PASS ") for l in srv.app_lines), srv.app_lines + assert any(l.startswith("SERVER tls-hub.test.net ") for l in srv.app_lines), srv.app_lines + finally: + await srv.stop() + await _notices(link_oper, r".", 1.0) + + +async def test_outbound_slow_handshake_crosses_deadline(ircd_tls_network, link_oper, healthy): + """The server flight dribbles so slowly it is still arriving when the 5 s + deadline fires. The hub must abort with a prompt timeout notice, keep the + control client served, and not spin -- the deadline teardown racing with + in-flight handshake data is the interesting window here.""" + srv = SidecarBogusServer("slow_complete", cert="tlspeer", chunk=24, chunk_delay=0.4) + port = await srv.start() + try: + await _connect_out(link_oper, port) + await srv.wait_event("accepted", 10.0) + cpu = asyncio.create_task(sample_cpu(HUB_CONTAINER, 6.0)) + alive = asyncio.create_task(_keep_alive(healthy, 6.0)) + note = await _wait_notice(link_oper, rf"TLS negotiation failed to {PEER_NAME}", CLOSE_MAX + 4.0) + assert "timed out" in note, note + _assert_no_spin(await cpu, "handshake dribbled across the deadline") + await alive # raises if the hub stopped answering mid-teardown + finally: + await srv.stop() + await _notices(link_oper, r".", 1.0) + + +async def test_outbound_slow_close_mid_handshake(ircd_tls_network, link_oper, healthy): + """Peer dribbles a partial flight then closes mid-handshake. The hub must + report a failure, stay responsive, and not spin.""" + srv = SidecarBogusServer("slow_close", cert="tlspeer", truncate=180, chunk=20, chunk_delay=0.1) + port = await srv.start() + try: + await _connect_out(link_oper, port) + await srv.wait_event("accepted", 10.0) + cpu = asyncio.create_task(sample_cpu(HUB_CONTAINER, 4.0)) + alive = asyncio.create_task(_keep_alive(healthy, 4.0)) + note = await _wait_notice( + link_oper, + rf"(TLS negotiation failed to {PEER_NAME}|Connection failed to {PEER_NAME}" + rf"|Link with {PEER_NAME} canceled)", + CLOSE_MAX + 2.0, + ) + _assert_no_spin(await cpu, "peer closed mid-handshake") + await alive + finally: + await srv.stop() + await _notices(link_oper, r".", 1.0) + + +async def test_outbound_byte_dribble_handshake(ircd_tls_network, link_oper, healthy): + """Extreme fragmentation: every handshake flight is written one byte at a + time. The hub's partial-read reassembly must still complete the link + without spinning.""" + srv = SidecarBogusServer("slow_complete", cert="tlspeer", chunk=1, chunk_delay=0.0) + port = await srv.start() + try: + await _connect_out(link_oper, port) + await srv.wait_event("accepted", 10.0) + cpu = asyncio.create_task(sample_cpu(HUB_CONTAINER, 4.0)) + await _keep_alive(healthy, 4.0) + _assert_no_spin(await cpu, "handshake fragmented to single bytes") + await srv.wait_event("line", 10.0) + assert any(l.startswith("SERVER tls-hub.test.net ") for l in srv.app_lines), srv.app_lines + finally: + await srv.stop() + await _notices(link_oper, r".", 1.0) + + +async def test_outbound_handshake_pre_delay_near_deadline(ircd_tls_network, link_oper, healthy): + """The peer stalls ~3.5 s (under the 5 s deadline) then completes the + handshake quickly. The link must come up -- the deadline must not fire on a + handshake that finishes in time -- with no spin and a responsive hub.""" + srv = SidecarBogusServer("slow_complete", cert="tlspeer", pre_delay=3.5) + port = await srv.start() + try: + await _connect_out(link_oper, port) + await srv.wait_event("accepted", 10.0) + cpu = asyncio.create_task(sample_cpu(HUB_CONTAINER, 6.0)) + await _keep_alive(healthy, 6.0) + _assert_no_spin(await cpu, "peer stalled just under the deadline") + await srv.wait_event("line", 10.0) + assert any(l.startswith("SERVER tls-hub.test.net ") for l in srv.app_lines), srv.app_lines + finally: + await srv.stop() + await _notices(link_oper, r".", 1.0) diff --git a/tests/tls/test_tls_datapath_repro.py b/tests/tls/test_tls_datapath_repro.py new file mode 100644 index 00000000..269078bb --- /dev/null +++ b/tests/tls/test_tls_datapath_repro.py @@ -0,0 +1,119 @@ +"""Reproductions for the TLS data-path findings of the 2026-08-30 review. + +Run against fix/tls-handshake-events: + + test_fatal_read_error_does_not_leak_queued_plaintext -> FAILS (bug #2) + test_handshake_failure_does_not_leak_plaintext_error -> PASSES (see note) + +Finding #2 (CONFIRMED, peer-observable): + A fatal error inside ircd_tls_recv() frees the SSL session and NULLs s_tls + but leaves FLAG_TLS set and does NOT mark the socket dead (read_packet's + FLAG_DEADSOCKET line is commented out). deliver_it() then tests + `IsTLS && s_tls`, finds s_tls NULL, and falls back to the *plaintext* + os_sendv path. When the server-initiated exit queues its + "ERROR :Closing Link: by (...)" line, that line is flushed + in the clear onto the still-open TLS socket. This test observes the server + name in cleartext on the wire. A one-line fix (SetFlag(cptr, + FLAG_DEADSOCKET) in the OpenSSL fatal-cleanup branch) makes it pass. + +Finding #3 (present in code, NOT peer-observable): + ircd_tls_negotiate() writes "ERROR :TLS handshake failed\r\n" to the raw fd + with write(2) on a fatal handshake -- cleartext into a TLS stream. In + practice the peer never sees it: the misbehaving peer leaves unconsumed + input in ircd's receive buffer, so close() emits an RST that discards the + just-written plaintext. This test documents that no cleartext reaches the + peer; the stray write() is therefore dead code worth deleting rather than a + live leak. +""" + +from __future__ import annotations + +import asyncio +import os +import re + +import pytest + +from tls.bogus_peer import BogusTLSClient, wait_for_eof + +pytestmark = [pytest.mark.tls, pytest.mark.asyncio] + + +async def test_handshake_failure_does_not_leak_plaintext_error(ircd_tls_network): + """Finding #3: no cleartext 'ERROR :TLS' line reaches a peer that sends + garbage after its ClientHello (the write(2) is discarded by RST-on-close).""" + hub = ircd_tls_network["hub"] + peer = BogusTLSClient(hub["host"], hub["tls_port"]) + try: + await peer.connect() + hello = peer.start_tls() + await peer.send_raw(hello) + await peer.feed(timeout=5.0) # server flight + await peer.send_raw(os.urandom(2048)) # garbage -> fatal + data, _ = await wait_for_eof(peer.reader, 8.0) + # Only TLS records (an encrypted alert), then EOF -- no cleartext. + assert b"ERROR" not in data, ( + f"cleartext leaked on handshake failure: " + f"{data[data.find(b'ERROR'):][:60]!r}" + ) + finally: + await peer.close() + + +async def _register_over_tls(peer: BogusTLSClient, nick: str, timeout: float = 15.0) -> None: + """Drive NICK/USER to 001 over a completed handshake, answering the + nospoof PING.""" + await peer.send_app(f"NICK {nick}\r\nUSER {nick} 0 * :repro\r\n") + got = "" + answered = 0 + deadline = asyncio.get_running_loop().time() + timeout + while " 001 " not in got: + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise AssertionError(f"did not register; last saw {got[-200:]!r}") + try: + got += await peer.recv_app(timeout=min(remaining, 5.0)) + except (asyncio.TimeoutError, ConnectionError): + continue + for m in re.finditer(r"PING :(\S+)", got): + if m.end() > answered: + await peer.send_app(f"PONG :{m.group(1)}\r\n") + answered = m.end() + + +async def test_fatal_read_error_does_not_leak_queued_plaintext(ircd_tls_network): + """Finding #2: a corrupt application record after registration frees the + TLS session; queued server output must not be flushed as plaintext during + teardown. On fix/tls-handshake-events this FAILS -- the "ERROR :Closing + Link ... tls-hub.test.net" line appears in cleartext on the TLS socket.""" + hub = ircd_tls_network["hub"] + # Tiny receive window so ircd cannot drain its sendq to us: queued replies + # stay queued when the fatal read tears the client down. + peer = BogusTLSClient(hub["host"], hub["tls_port"], rcvbuf=512) + try: + await peer.connect() + await peer.complete_handshake(timeout=8.0) + await _register_over_tls(peer, "reprofatal") + + # Generate a wad of server output without reading it, so ircd's sendq + # to us is non-empty (but under the sendq limit -> no clean dead_link). + for _ in range(8): + await peer.send_app("VERSION\r\nLUSERS\r\nADMIN\r\nTIME\r\n") + + # Corrupt an application record: valid length/type, broken AEAD tag. + corrupt = bytearray(peer.app_bytes("VERSION\r\n")) + corrupt[-1] ^= 0xFF + corrupt[-2] ^= 0xFF + await peer.send_raw(bytes(corrupt)) + + # On a TLS socket the peer must only ever see TLS records (0x14-0x17); + # the server name in cleartext means queued data was flushed through + # the plaintext os_sendv path after s_tls was cleared. + data, _ = await wait_for_eof(peer.reader, 12.0) + runs = re.findall(rb"[\x20-\x7e]{8,}", data) + assert b"tls-hub.test.net" not in data and b"Closing Link" not in data, ( + "plaintext server data leaked onto the TLS socket during teardown: " + f"{[r for r in runs if b'Closing Link' in r or b'test.net' in r][:3]}" + ) + finally: + await peer.close() diff --git a/tests/tls/test_tls_keyupdate.py b/tests/tls/test_tls_keyupdate.py new file mode 100644 index 00000000..7f50c63d --- /dev/null +++ b/tests/tls/test_tls_keyupdate.py @@ -0,0 +1,162 @@ +"""Post-handshake TLS 1.3 KeyUpdate: the one real-TLS exercise of the ircd's +cross-direction data path. + +A peer-initiated KeyUpdate is the only standard way (client renegotiation is +off by default on modern OpenSSL) to make a completed TLS session do more +handshake-shaped work mid-stream, which is what the con_tls_want_rd/wr +machinery in ircd_tls_recv()/sendv() + update_write() exists to handle. These +tests drive a real KeyUpdate through libssl (see keyupdate_peer.py, since +Python's ssl module cannot) and assert the server keeps working with no CPU +spin. + +Note: the *deterministic* cross-direction stall/spin (an empty sendq with a +full socket, or a partial KeyUpdate arriving mid-write) sits on a razor's edge +of socket-buffer timing that is not reliably reproducible from a peer; those +remain covered by fault injection. What is reliable, and what these tests +lock in, is that a mid-session KeyUpdate never crashes, hangs, spins, or drops +the rekeyed application stream. + +OpenSSL-specific (KeyUpdate handling differs per backend); skipped otherwise. +""" + +from __future__ import annotations + +import asyncio +import os +import re + +import pytest + +from irc_client import IRCClient +from tls.bogus_peer import sample_cpu +from tls.keyupdate_peer import KeyUpdatePeer + +pytestmark = [ + pytest.mark.tls, + pytest.mark.asyncio, + pytest.mark.skipif( + os.environ.get("TLS_BACKEND", "openssl") != "openssl", + reason="KeyUpdate handling is backend-specific; test targets OpenSSL", + ), +] + +HUB_CONTAINER = "ircu-tls-hub" +CPU_SPIN_THRESHOLD = 50.0 + + +def _register(peer: KeyUpdatePeer, nick: str, timeout: float = 15.0) -> bytes: + """NICK/USER to 001 over the ctypes peer, answering the nospoof PING.""" + peer.write_app(f"NICK {nick}\r\nUSER {nick} 0 * :keyupdate\r\n".encode()) + got = b"" + answered = 0 + for _ in range(40): + chunk = peer.read_app(timeout=timeout) + if not chunk: + break + got += chunk + for m in re.finditer(rb"PING :(\S+)", got): + if m.end() > answered: + peer.write_app(b"PONG :" + m.group(1) + b"\r\n") + answered = m.end() + if b" 001 " in got: + return got + raise AssertionError(f"peer did not register; saw {got[-200:]!r}") + + +def _command_reply(peer: KeyUpdatePeer, command: bytes, needle: bytes, + timeout: float = 5.0) -> bool: + """Send a command and read until `needle` (a token echoed back) appears.""" + peer.write_app(command) + got = b"" + for _ in range(30): + chunk = peer.read_app(timeout=timeout) + if not chunk: + break + got += chunk + for m in re.finditer(rb"PING :(\S+)", got): + peer.write_app(b"PONG :" + m.group(1) + b"\r\n") + if needle in got: + return True + return False + + +async def _healthy_pong(hub: dict, token: str) -> bool: + c = IRCClient() + await c.connect(hub["host"], hub["port"]) + await c.register("kuphealthy", "probe", "liveness") + try: + await c.send(f"PING :{token}") + for _ in range(20): + msg = await c.recv(timeout=2.0) + if msg.command == "PONG" and token in msg.params[-1]: + return True + return False + finally: + try: + await c.disconnect() + except Exception: + pass + + +async def test_keyupdate_midsession_survives_without_spin(ircd_tls_network): + """A registered TLS client sends a KeyUpdate and then keeps talking: the + server must process the rekey, answer post-KeyUpdate commands (rekeyed data + both directions), and not spin.""" + hub = ircd_tls_network["hub"] + peer = KeyUpdatePeer(hub["host"], hub["tls_port"]) + + def setup(): + peer.connect() + peer.handshake() + _register(peer, "kupdate1") + rec = peer.key_update_record(requested=True) + assert rec and rec[0] == 0x17, f"expected an encrypted record, got {rec[:8]!r}" + peer.send_raw(rec) # KeyUpdate onto the wire, whole + + await asyncio.to_thread(setup) + + samples = await sample_cpu(HUB_CONTAINER, 3.0) + assert await _healthy_pong(hub, "kupd-mid"), "healthy client lost service" + + # The session is rekeyed: a command sent under the new key must still be + # processed and its reply delivered. + ok = await asyncio.to_thread( + _command_reply, peer, b"PING :kupdalive\r\n", b"kupdalive" + ) + assert ok, "server stopped responding after KeyUpdate" + + assert max(samples or [0]) < CPU_SPIN_THRESHOLD, f"hub spun: {samples}" + await asyncio.to_thread(peer.close) + + +async def test_partial_keyupdate_then_complete_survives(ircd_tls_network): + """A KeyUpdate record delivered in two socket writes (a partial post- + handshake record parked in the server, then the remainder) must not wedge + or spin the server, and the session must continue once completed.""" + hub = ircd_tls_network["hub"] + peer = KeyUpdatePeer(hub["host"], hub["tls_port"]) + held = {} + + def setup_partial(): + peer.connect() + peer.handshake() + _register(peer, "kupdate2") + rec = peer.key_update_record(requested=True) + held["rec"] = rec + peer.send_raw(rec[:-4]) # withhold the last 4 bytes + + await asyncio.to_thread(setup_partial) + + samples = await sample_cpu(HUB_CONTAINER, 3.0) # partial record pending + assert await _healthy_pong(hub, "kupd-part"), "healthy client lost service" + assert max(samples or [0]) < CPU_SPIN_THRESHOLD, f"hub spun on partial KeyUpdate: {samples}" + + def complete(): + peer.send_raw(held["rec"][-4:]) # deliver the remainder + + await asyncio.to_thread(complete) + ok = await asyncio.to_thread( + _command_reply, peer, b"PING :kupdpart\r\n", b"kupdpart" + ) + assert ok, "server stopped responding after completing the KeyUpdate" + await asyncio.to_thread(peer.close) diff --git a/tests/tls/test_tls_rehash.py b/tests/tls/test_tls_rehash.py new file mode 100644 index 00000000..8e756b82 --- /dev/null +++ b/tests/tls/test_tls_rehash.py @@ -0,0 +1,134 @@ +"""TLS certificate rotation via REHASH under a live connection. + +`REHASH s` -> ircd_tls_rehash() -> ircd_tls_init() rebuilds the server SSL +context from the on-disk cert/key and drops the daemon's reference to the old +one; an already-negotiated session must keep the context it handshaked on alive +on its own, so live connections must survive while new connections pick up the +rotated certificate. This is the routine ops path (cert renewal) and is +verified across all three backends. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import socket +import ssl + +import pytest + +import tls_certs +from debug_support import docker_exec +from irc_client import IRCClient +from tls.helpers import oper_up + +pytestmark = [ + pytest.mark.tls, + pytest.mark.asyncio, + # New connections pick up the rotated cert, and the live session must + # survive the context swap, on every backend. ircd_tls_rehash() rebuilds + # the global context via ircd_tls_init(); an already-negotiated session + # must keep the context it handshaked on alive on its own (OpenSSL by + # SSL_CTX refcount, gnutls/libtls by their per-session credential/keypair + # ownership). Verified for all three backends. +] + +HUB = "ircu-tls-hub" +CERTDIR = "/opt/ircu/lib/certs" + + +def _server_cert_fp(host: str, port: int, timeout: float = 5.0) -> str: + """SHA-256 of the DER server certificate a fresh TLS client is presented — + the same value ircu records as the TLS fingerprint.""" + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + with socket.create_connection((host, port), timeout=timeout) as s: + with ctx.wrap_socket(s, server_hostname=host) as ss: + der = ss.getpeercert(binary_form=True) + return hashlib.sha256(der).hexdigest() + + +async def _pong(client: IRCClient, token: str, timeout: float = 5.0) -> bool: + await client.send(f"PING :{token}") + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while loop.time() < deadline: + try: + msg = await client.recv(timeout=deadline - loop.time()) + except (asyncio.TimeoutError, ConnectionError): + return False + if msg.command == "PONG" and token in msg.params[-1]: + return True + return False + + +def _sh(*cmd: str) -> None: + docker_exec(HUB, "sh", "-c", " ".join(cmd)) + + +async def test_tls_cert_rotation_via_rehash(ircd_tls_network): + hub = ircd_tls_network["hub"] + host, port = hub["host"], hub["tls_port"] + fps = tls_certs.FINGERPRINTS + + # Baseline: the hub serves its own certificate. + fp_old = await asyncio.to_thread(_server_cert_fp, host, port) + assert fp_old == fps["hub"], f"unexpected baseline cert {fp_old}" + + # A TLS client that must survive the rehash, and an oper to drive it. + survivor = IRCClient() + await survivor.connect_tls(host, port) + await survivor.register("rehashsurv", "surv", "rehash survivor") + oper = IRCClient() + await oper.connect(hub["host"], hub["port"]) + await oper.register("rehashop", "op", "rehash oper") + assert (await oper_up(oper)).command == "381" + + # Back up the live cert/key so we can put them back afterwards. + await asyncio.to_thread( + _sh, + f"cp {CERTDIR}/hub.pem {CERTDIR}/hub.pem.orig", + f"&& cp {CERTDIR}/hub.key {CERTDIR}/hub.key.orig", + ) + try: + # Rotate: install a different valid cert/key as the hub's own. + await asyncio.to_thread( + _sh, + f"cp {CERTDIR}/tlspeer.pem {CERTDIR}/hub.pem", + f"&& cp {CERTDIR}/tlspeer.key {CERTDIR}/hub.key", + f"&& chown ircu:ircu {CERTDIR}/hub.pem {CERTDIR}/hub.key", + ) + await oper.send("REHASH s") + await asyncio.sleep(1.0) # ircd_tls_init() is synchronous; small settle + + # 1) The live connection survives the rehash. + assert await _pong(survivor, "rehash-alive"), "live TLS client dropped by rehash" + + # 2) New connections are served the rotated certificate. + fp_new = await asyncio.to_thread(_server_cert_fp, host, port) + assert fp_new == fps["tlspeer"], f"rotated cert not served: {fp_new}" + assert fp_new != fp_old + + # 3) A fresh client can still fully register against the new cert. + fresh = IRCClient() + await fresh.connect_tls(host, port) + msgs = await fresh.register("rehashfresh", "fr", "post-rotation") + assert any(m.command == "001" for m in msgs) + await fresh.disconnect() + finally: + # Restore the original cert/key and rehash back, so the shared topology + # is left as we found it for any later tests. + await asyncio.to_thread( + _sh, + f"cp {CERTDIR}/hub.pem.orig {CERTDIR}/hub.pem", + f"&& cp {CERTDIR}/hub.key.orig {CERTDIR}/hub.key", + f"&& chown ircu:ircu {CERTDIR}/hub.pem {CERTDIR}/hub.key", + ) + await oper.send("REHASH s") + await asyncio.sleep(1.0) + try: + await survivor.disconnect() + await oper.disconnect() + except Exception: + pass diff --git a/tests/tls/test_tls_s2s_burst.py b/tests/tls/test_tls_s2s_burst.py new file mode 100644 index 00000000..d75c75bb --- /dev/null +++ b/tests/tls/test_tls_s2s_burst.py @@ -0,0 +1,142 @@ +"""S2S TLS link carrying a populated burst, initiated from each side. + +Links tls-hub and tls-leaf over TLS with the hub in both TLS roles (client +when it initiates, server when the leaf does) and a non-trivial user burst, +and requires the link to stay up and the burst to have arrived on the far +side. Regression coverage for the TLS handshake / send-path code on server +links, which the other S2S tests exercise only with an empty network. +""" + +from __future__ import annotations + +import asyncio +import logging + +import pytest + +from irc_client import IRCClient +from tls.helpers import ( + links_contains, + oper_up, + populate_channels, +) + +log = logging.getLogger(__name__) + +pytestmark = [pytest.mark.tls, pytest.mark.tls_stress, pytest.mark.asyncio] + +HUB_NAME = "tls-hub.test.net" +LEAF_NAME = "tls-leaf.test.net" + + +async def _oper(host: str, port: int, nick: str) -> IRCClient: + c = IRCClient() + await c.connect(host, port) + await c.register(nick, "oper", "Oper") + msg = await oper_up(c) + assert msg.command == "381", msg + # See server notices (net breaks, TLS failures) for diagnosis. + await c.send(f"MODE {nick} +s +65535") + return c + + +async def _drain(c: IRCClient, seconds: float, tag: str) -> list: + """Read everything for `seconds`, logging each line.""" + out = [] + loop = asyncio.get_running_loop() + deadline = loop.time() + seconds + while True: + remaining = deadline - loop.time() + if remaining <= 0: + return out + try: + msg = await c.recv(timeout=remaining) + except asyncio.TimeoutError: + return out + out.append(msg) + log.info("%s <- %s", tag, msg.raw) + + +async def _wait_linked(c: IRCClient, peer: str, tag: str, timeout: float = 45.0) -> None: + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while loop.time() < deadline: + await _drain(c, 1.0, tag) + if await links_contains(c, peer, timeout=5.0): + return + raise TimeoutError(f"{tag}: {peer} never appeared in LINKS") + + +async def _wait_unlinked(c: IRCClient, peer: str, tag: str, timeout: float = 30.0) -> None: + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while loop.time() < deadline: + await _drain(c, 1.0, tag) + if not await links_contains(c, peer, timeout=5.0): + return + raise TimeoutError(f"{tag}: {peer} still in LINKS") + + +async def _whois_ok(c: IRCClient, nick: str) -> bool: + await c.send(f"WHOIS {nick}") + while True: + msg = await c.recv(timeout=10.0) + if msg.command == "311": + return True + if msg.command in ("401", "318"): + return False + + +async def _link_and_check( + initiator: IRCClient, other: IRCClient, peer: str, port: int, + other_peer: str, probe_nick: str, tag: str, +) -> None: + await initiator.send(f"CONNECT {peer} {port}") + await _wait_linked(initiator, peer, f"{tag}/init") + await _wait_linked(other, other_peer, f"{tag}/other") + # Let the burst finish and any delayed failure show up. + await _drain(initiator, 8.0, f"{tag}/init") + await _drain(other, 3.0, f"{tag}/other") + assert await links_contains(initiator, peer), f"{tag}: link dropped (initiator side)" + assert await links_contains(other, other_peer), f"{tag}: link dropped (other side)" + assert await _whois_ok(other, probe_nick), f"{tag}: burst did not arrive on other side" + + +@pytest.mark.timeout(400) +async def test_s2s_tls_burst_both_directions(ircd_tls_network): + hub = ircd_tls_network["hub"] + leaf = ircd_tls_network["leaf"] + crowd: list[IRCClient] = [] + hub_op = leaf_op = None + try: + hub_op = await _oper(hub["host"], hub["port"], "s2sophub") + leaf_op = await _oper(leaf["host"], leaf["port"], "s2sopleaf") + + # Start from an unlinked state. + if await links_contains(hub_op, LEAF_NAME): + await hub_op.send(f"SQUIT {LEAF_NAME} :reset") + await _wait_unlinked(hub_op, LEAF_NAME, "reset/hub") + await _wait_unlinked(leaf_op, HUB_NAME, "reset/leaf") + + crowd = await populate_channels(hub) + log.info("crowd of %d populated on hub", len(crowd)) + + # Direction 1: hub initiates (hub is TLS client, sends the big burst). + await _link_and_check(hub_op, leaf_op, LEAF_NAME, 4401, HUB_NAME, + "crwd00", "hub->leaf") + + await hub_op.send(f"SQUIT {LEAF_NAME} :direction swap") + await _wait_unlinked(hub_op, LEAF_NAME, "swap/hub") + await _wait_unlinked(leaf_op, HUB_NAME, "swap/leaf") + + # Direction 2: leaf initiates (hub is TLS server). + await _link_and_check(leaf_op, hub_op, HUB_NAME, 4441, LEAF_NAME, + "crwd00", "leaf->hub") + finally: + for c in [hub_op, leaf_op, *crowd]: + if c is None: + continue + try: + await c.disconnect() + except Exception: + pass From 5a839b11fbe03361bd862bf4df9628659c905c89 Mon Sep 17 00:00:00 2001 From: MrIron Date: Tue, 1 Sep 2026 10:52:11 +0200 Subject: [PATCH 2/2] tests: C unit suite for the TLS I/O core (tls_io_t) Drive tls_io.c through a scripted fake tls_backend_* so the core's logic is tested deterministically, with no TLS library, socket, or docker involved: - socket-interest model: full 36-case truth table of cross-direction blocking states x queued output x /LIST (the anti-spin and anti-stall invariants); - tls_io_sendv() drain: partial writes drained in-call, con_rexmit parking and resumption across calls, the rexmit-bytes-never-credited rule, the zero-credit success (a rexmit drain that excised the last queued message is progress, not a block), priority-before-normal and full three-way (rexmit, prio, normal) wire ordering, and fatal teardown from both the queue walk and the rexmit drain; - tls_io_recv() blocked-direction recording and fatal teardown; - fingerprint storage edge cases (non-SHA-256 lengths, hex length limits, Cloudflare-port suppression); - ircd_tls_negotiate() trust policy matrix over scripted tls_peer material (cert-required, verifypeer, backend reason fall-through, digest vs pre-formatted hex fingerprint hand-off, no-session re-entry). Scenarios mimic the real caller (msgq_delete of count_out between calls, as send_queued does) against a real MsgQ, and the fake backend captures the exact byte stream accepted so ordering and duplication bugs are caught, not just return codes. The backend error-classification code itself (SSL_get_error and friends) is intentionally out of scope -- the docker suite's TLS_BACKEND matrix covers that with real handshakes. Runs under make check (no docker), so it also runs on FreeBSD. --- ircd/test/Makefile.am | 6 +- ircd/test/tls_io_t.c | 702 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 707 insertions(+), 1 deletion(-) create mode 100644 ircd/test/tls_io_t.c diff --git a/ircd/test/Makefile.am b/ircd/test/Makefile.am index 0f883961..03d5fdbf 100644 --- a/ircd/test/Makefile.am +++ b/ircd/test/Makefile.am @@ -1,7 +1,7 @@ AM_CPPFLAGS = -I$(top_srcdir)/include -I../.. AM_CFLAGS = -g -Wall -check_PROGRAMS = cidr_lookups_t ircd_chattr_t ircd_in_addr_t ircd_match_t ircd_string_t msgq_excise_t +check_PROGRAMS = cidr_lookups_t ircd_chattr_t ircd_in_addr_t ircd_match_t ircd_string_t msgq_excise_t tls_io_t TESTS = $(check_PROGRAMS) @@ -13,6 +13,10 @@ msgq_excise_t_CPPFLAGS = $(AM_CPPFLAGS) -DIRCU2_BUILD msgq_excise_t_SOURCES = msgq_excise_t.c test_stub.c msgq_excise_t_LDADD = ../msgq.o ../ircd_snprintf.o ../ircd_alloc.o ../ircd_string.o +tls_io_t_CPPFLAGS = $(AM_CPPFLAGS) -DIRCU2_BUILD +tls_io_t_SOURCES = tls_io_t.c test_stub.c +tls_io_t_LDADD = ../tls_io.o ../msgq.o ../ircd_snprintf.o ../ircd_alloc.o ../ircd_string.o + ircd_chattr_t_SOURCES = ircd_chattr_t.c test_stub.c ircd_chattr_t_LDADD = ../ircd_string.o diff --git a/ircd/test/tls_io_t.c b/ircd/test/tls_io_t.c new file mode 100644 index 00000000..6b62483d --- /dev/null +++ b/ircd/test/tls_io_t.c @@ -0,0 +1,702 @@ +/* tls_io_t.c - unit tests for the core TLS I/O layer (tls_io.c). + * + * Drives tls_io through a scripted fake backend (tls_backend_*), covering: + * - the socket-interest model (tls_want_writable / tls_desired_events): + * full truth table of cross-direction blocking states; + * - the tls_io_sendv() drain: partial writes, con_rexmit parking and + * resumption, the rexmit-bytes-not-credited rule when a priority message + * jumps the queue, the zero-credit success (a rexmit drain that excised + * the last queued message is progress, not a block), and fatal teardown; + * - tls_io_recv() blocked-direction recording and fatal teardown; + * - fingerprint storage edge cases (length, hex form, Cloudflare ports); + * - the ircd_tls_negotiate() trust policy matrix over scripted tls_peer + * material (cert-required, verifypeer, fingerprint hand-off). + * + * Each sendv scenario mimics the real caller (send_queued/deliver_it): + * msgq_delete(count_out) after any successful delivery, state carried + * between calls. The fake backend captures the exact byte stream "sent" + * so ordering and duplication bugs are caught, not just return codes. + */ + +#include "client.h" +#include "ircd_features.h" +#include "ircd_log.h" +#include "ircd_string.h" +#include "ircd_tls.h" +#include "listener.h" +#include "msgq.h" +#include "tls_io.h" + +#include +#include +#include + +extern struct Client me; + +/* --- stubs for symbols pulled in by msgq.o that these tests never reach --- */ +int feature_bool(enum Feature feat) { (void)feat; return 0; } +/* Large enough for FEAT_BUFFERPOOL so msgq_alloc() actually allocates. */ +int feature_int(enum Feature feat) { (void)feat; return 1 << 20; } +void flush_connections(struct Client *cptr) { (void)cptr; } +void kill_highest_sendq(int servers_too) { (void)servers_too; } +int send_reply(struct Client *to, int reply, ...) { (void)to; (void)reply; return 0; } +void server_panic(const char *message) { (void)message; } +const char *visible_username(const struct Client *cptr) { (void)cptr; return ""; } + +/* --- settable policy predicates (real ones live in s_conf.c) --- */ +static int fake_cert_required; +static int fake_verifypeer; +int ircd_tls_peer_cert_required(const struct Client *cptr) +{ (void)cptr; return fake_cert_required; } +int ircd_tls_verifypeer_enabled(const struct Client *cptr) +{ (void)cptr; return fake_verifypeer; } + +/* --- scripted fake backend ------------------------------------------------ */ + +#define MAX_STEPS 16 + +/** One scripted outcome for a tls_backend_write() call. */ +struct wstep { + IOResult io; /**< result to return */ + unsigned int accept; /**< bytes to accept on IO_SUCCESS (capped to len) */ + enum ircd_tls_want want; /**< blocked direction on IO_BLOCKED */ +}; + +static struct wstep wsteps[MAX_STEPS]; +static int wstep_next, wstep_count; + +static char wire[4096]; /**< exact byte stream the backend accepted */ +static unsigned int wire_len; + +static struct { /**< scripted next tls_backend_read() result */ + IOResult io; + const char *data; + enum ircd_tls_want want; +} rstep; + +static struct { /**< scripted next tls_backend_handshake() */ + IOResult io; + struct tls_peer peer; + const char *reason; + enum ircd_tls_want want; +} hstep; + +static int drop_calls; + +static void script_reset(void) +{ + wstep_next = wstep_count = 0; + wire_len = 0; + drop_calls = 0; + memset(&rstep, 0, sizeof(rstep)); + memset(&hstep, 0, sizeof(hstep)); +} + +static void script_write(IOResult io, unsigned int accept, + enum ircd_tls_want want) +{ + assert(wstep_count < MAX_STEPS); + wsteps[wstep_count].io = io; + wsteps[wstep_count].accept = accept; + wsteps[wstep_count].want = want; + ++wstep_count; +} + +IOResult tls_backend_write(struct Client *cptr, const char *buf, + unsigned int len, unsigned int *written, + enum ircd_tls_want *want) +{ + struct wstep *s; + + (void)cptr; + assert(len > 0); /* the core must never write 0 bytes */ + assert(wstep_next < wstep_count); /* the script must cover every call */ + s = &wsteps[wstep_next++]; + *written = 0; + if (s->io == IO_SUCCESS) { + unsigned int n = s->accept < len ? s->accept : len; + assert(n > 0); + assert(wire_len + n <= sizeof(wire)); + memcpy(wire + wire_len, buf, n); + wire_len += n; + *written = n; + } + else if (s->io == IO_BLOCKED) + *want = s->want; + return s->io; +} + +IOResult tls_backend_read(struct Client *cptr, char *buf, unsigned int length, + unsigned int *count_out, enum ircd_tls_want *want) +{ + (void)cptr; + *count_out = 0; + if (rstep.io == IO_SUCCESS) { + unsigned int n = strlen(rstep.data); + assert(n <= length); + memcpy(buf, rstep.data, n); + *count_out = n; + } + else if (rstep.io == IO_BLOCKED) + *want = rstep.want; + return rstep.io; +} + +IOResult tls_backend_handshake(struct Client *cptr, struct tls_peer *peer, + char *reason, size_t reasonlen, + enum ircd_tls_want *want) +{ + (void)cptr; + if (hstep.io == IO_SUCCESS) + *peer = hstep.peer; + else if (hstep.io == IO_BLOCKED) + *want = hstep.want; + else if (hstep.io == IO_FAILURE && hstep.reason && reason && reasonlen) + ircd_strncpy(reason, hstep.reason, reasonlen - 1); + return hstep.io; +} + +void tls_backend_drop(struct Client *cptr) +{ + ++drop_calls; + s_tls(&cli_socket(cptr)) = NULL; /* mirror the real backends */ +} + +/* --- client/connection fixture ------------------------------------------- */ + +static struct Connection conn; +static struct Client cli; +static struct Listener lst; + +static struct Client *fix(void) +{ + MsgQClear(&con_sendQ(&conn)); /* free MsgBufs from the prior test */ + memset(&conn, 0, sizeof(conn)); + memset(&cli, 0, sizeof(cli)); + memset(&lst, 0, sizeof(lst)); + cli.cli_connect = &conn; + msgq_init(&con_sendQ(&conn)); + script_reset(); + fake_cert_required = fake_verifypeer = 0; + return &cli; +} + +/** Queue \a text (msgq_make appends CRLF); returns wire length of the msg. */ +static unsigned int enq(struct Client *c, const char *text, int prio) +{ + msgq_add(&cli_sendQ(c), msgq_make(&me, "%s", text), prio); + return strlen(text) + 2; +} + +/** Call tls_io_sendv() and consume credited bytes exactly as send_queued / + * deliver_it do (msgq_delete of count_out). */ +static IOResult sendv_and_consume(struct Client *c, unsigned int *out) +{ + unsigned int count_in = 0, count_out = 0; + IOResult io = tls_io_sendv(c, &cli_sendQ(c), &count_in, &count_out); + + if (count_out) + msgq_delete(&cli_sendQ(c), count_out); + *out = count_out; + return io; +} + +static int wire_is(const char *expect) +{ + unsigned int n = strlen(expect); + return wire_len == n && !memcmp(wire, expect, n); +} + +/* --- A: socket-interest truth table --------------------------------------- */ + +/* Every combination of blocked-direction state x queue x listing must map to + * exactly one interest decision: + * - a read waiting to write, or a write waiting to write => writable; + * - otherwise a write waiting to read => NOT writable (level-triggered + * writable would spin); + * - otherwise the plaintext base rule (queued output or an active /LIST). + * Readable is always wanted. */ +static void test_interest_truth_table(void) +{ + static const enum ircd_tls_want wants[] = + { IRCD_TLS_WANT_NONE, IRCD_TLS_WANT_READ, IRCD_TLS_WANT_WRITE }; + int ircd_wr, ircd_rd, queued, listing, cases = 0; + + for (ircd_rd = 0; ircd_rd < 3; ++ircd_rd) + for (ircd_wr = 0; ircd_wr < 3; ++ircd_wr) + for (queued = 0; queued < 2; ++queued) + for (listing = 0; listing < 2; ++listing) { + struct Client *c = fix(); + int base, expect; + + if (queued) + enq(c, "MSG", 0); + con_listing(&conn) = listing ? (struct ListingArgs *)&lst : 0; + cli_tls_want_rd(c) = wants[ircd_rd]; + cli_tls_want_wr(c) = wants[ircd_wr]; + + base = queued || listing; + expect = (wants[ircd_rd] == IRCD_TLS_WANT_WRITE + || wants[ircd_wr] == IRCD_TLS_WANT_WRITE) ? 1 + : (wants[ircd_wr] == IRCD_TLS_WANT_READ) ? 0 + : base; + + assert(tls_want_writable(c) == expect); + assert(tls_desired_events(c) == + (SOCK_EVENT_READABLE | (expect ? SOCK_EVENT_WRITABLE : 0))); + ++cases; + } + + assert(cases == 36); + printf("Passed: interest truth table (%d cases)\n", cases); +} + +/* --- B: tls_io_sendv drain ------------------------------------------------ */ + +/* Two whole messages accepted in one pass: full credit, clean wants. */ +static void test_sendv_clean_write(void) +{ + struct Client *c = fix(); + unsigned int out, len = 0; + + len += enq(c, "M1", 0); + len += enq(c, "M2", 0); + script_write(IO_SUCCESS, 4, IRCD_TLS_WANT_NONE); + script_write(IO_SUCCESS, 4, IRCD_TLS_WANT_NONE); + + assert(sendv_and_consume(c, &out) == IO_SUCCESS); + assert(out == len); + assert(wire_is("M1\r\nM2\r\n")); + assert(MsgQLength(&cli_sendQ(c)) == 0); + assert(cli_tls_want_wr(c) == IRCD_TLS_WANT_NONE); + assert(conn.con_rexmit == NULL); + printf("Passed: sendv clean multi-message write\n"); +} + +/* A short record write is not a full socket: the remainder is parked in + * con_rexmit and drained to completion within the same call, full credit. */ +static void test_sendv_short_write_drained_in_call(void) +{ + struct Client *c = fix(); + unsigned int out, len; + + len = enq(c, "ABCDEFG", 0); /* "ABCDEFG\r\n", 9 bytes */ + script_write(IO_SUCCESS, 3, IRCD_TLS_WANT_NONE); + script_write(IO_SUCCESS, 9, IRCD_TLS_WANT_NONE); /* remainder (6) */ + + assert(sendv_and_consume(c, &out) == IO_SUCCESS); + assert(out == len); + assert(wire_is("ABCDEFG\r\n")); + assert(conn.con_rexmit == NULL); + assert(MsgQLength(&cli_sendQ(c)) == 0); + printf("Passed: sendv short write drained within the call\n"); +} + +/* Short write, then block: partial credit, remainder parked, and the blocked + * direction (a write waiting to READ) must drop writable interest even with + * data still queued -- the anti-spin invariant. Resuming after the "socket" + * unblocks drains the parked remainder; with nothing else queued that is the + * zero-credit success send_queued must treat as progress, not a block. */ +static void test_sendv_block_resume_zero_credit(void) +{ + struct Client *c = fix(); + unsigned int out; + + enq(c, "ABCDEFG", 0); + script_write(IO_SUCCESS, 3, IRCD_TLS_WANT_NONE); + script_write(IO_BLOCKED, 0, IRCD_TLS_WANT_READ); + + assert(sendv_and_consume(c, &out) == IO_BLOCKED); + assert(out == 3); + assert(conn.con_rexmit != NULL); + assert(conn.con_rexmit_len == 6); + assert(!memcmp(conn.con_rexmit, "DEFG\r\n", 6)); + assert(cli_tls_want_wr(c) == IRCD_TLS_WANT_READ); + assert(MsgQLength(&cli_sendQ(c)) == 6); /* 3 credited bytes consumed */ + /* write-waiting-to-read: writable interest must be off despite the queue */ + assert(tls_want_writable(c) == 0); + + /* peer sent its records; the read side ran; now retry the drain */ + script_reset(); + script_write(IO_SUCCESS, 6, IRCD_TLS_WANT_NONE); + + assert(sendv_and_consume(c, &out) == IO_SUCCESS); + assert(out == 0); /* rexmit bytes are never credited */ + assert(wire_is("DEFG\r\n")); + assert(conn.con_rexmit == NULL); + assert(MsgQLength(&cli_sendQ(c)) == 0); /* excised by identity */ + assert(cli_tls_want_wr(c) == IRCD_TLS_WANT_NONE); + printf("Passed: sendv block, resume, zero-credit success\n"); +} + +/* A priority message enqueued while the write was blocked: the rexmit drain + * must not credit its bytes (msgq_delete would eat the priority message that + * jumped ahead), the drained message leaves by identity (msgq_excise), and + * the priority message goes out next with normal credit. */ +static void test_sendv_rexmit_prio_jump(void) +{ + struct Client *c = fix(); + unsigned int out, ping_len; + + enq(c, "NORMALMSG", 0); /* 11 bytes on the wire */ + script_write(IO_BLOCKED, 0, IRCD_TLS_WANT_WRITE); + + assert(sendv_and_consume(c, &out) == IO_BLOCKED); + assert(out == 0); + assert(conn.con_rexmit != NULL && conn.con_rexmit_len == 11); + assert(cli_tls_want_wr(c) == IRCD_TLS_WANT_WRITE); + assert(tls_want_writable(c) == 1); /* genuinely wants writable */ + + ping_len = enq(c, "PING", 1); /* prio jumps ahead while blocked */ + + script_reset(); + script_write(IO_SUCCESS, 11, IRCD_TLS_WANT_NONE); /* rexmit drain */ + script_write(IO_SUCCESS, 6, IRCD_TLS_WANT_NONE); /* then the PING */ + + assert(sendv_and_consume(c, &out) == IO_SUCCESS); + assert(out == ping_len); /* ONLY the ping is credited */ + assert(wire_is("NORMALMSG\r\nPING\r\n")); /* exact stream, no dup/reorder */ + assert(MsgQLength(&cli_sendQ(c)) == 0); + assert(conn.con_rexmit == NULL); + printf("Passed: sendv rexmit drain with priority jump-ahead\n"); +} + +/* A priority message enqueued after normal messages must still go out first: + * msgq_mapiov order is (partial-normal, prio, normal), and tls_io_sendv sends + * in mapped order. */ +static void test_sendv_prio_transmits_first(void) +{ + struct Client *c = fix(); + unsigned int out, len = 0; + + len += enq(c, "N1", 0); + len += enq(c, "N2", 0); + len += enq(c, "PING", 1); /* queued last, must send first */ + script_write(IO_SUCCESS, 64, IRCD_TLS_WANT_NONE); + script_write(IO_SUCCESS, 64, IRCD_TLS_WANT_NONE); + script_write(IO_SUCCESS, 64, IRCD_TLS_WANT_NONE); + + assert(sendv_and_consume(c, &out) == IO_SUCCESS); + assert(out == len); + assert(wire_is("PING\r\nN1\r\nN2\r\n")); + assert(MsgQLength(&cli_sendQ(c)) == 0); + printf("Passed: sendv priority message transmits first\n"); +} + +/* Full three-way order on the wire: a blocked message's remainder (the + * partial-normal analog, via con_rexmit) drains first, then a priority + * message that jumped ahead, then a normal message queued behind it -- + * with only the latter two credited. */ +static void test_sendv_three_way_order(void) +{ + struct Client *c = fix(); + unsigned int out, credited = 0; + + enq(c, "NORMALMSG", 0); + script_write(IO_BLOCKED, 0, IRCD_TLS_WANT_WRITE); + assert(sendv_and_consume(c, &out) == IO_BLOCKED); + assert(out == 0 && conn.con_rexmit != NULL); + + credited += enq(c, "PING", 1); /* prio jumps ahead while blocked */ + credited += enq(c, "AFTER", 0); /* normal queued behind it */ + + script_reset(); + script_write(IO_SUCCESS, 64, IRCD_TLS_WANT_NONE); /* rexmit drain */ + script_write(IO_SUCCESS, 64, IRCD_TLS_WANT_NONE); /* PING */ + script_write(IO_SUCCESS, 64, IRCD_TLS_WANT_NONE); /* AFTER */ + + assert(sendv_and_consume(c, &out) == IO_SUCCESS); + assert(out == credited); /* rexmit bytes never credited */ + assert(wire_is("NORMALMSG\r\nPING\r\nAFTER\r\n")); + assert(MsgQLength(&cli_sendQ(c)) == 0); + assert(conn.con_rexmit == NULL); + printf("Passed: sendv three-way wire order (rexmit, prio, normal)\n"); +} + +/* Fatal backend error mid-queue: count_out is zeroed (nothing for the caller + * to delete on a dead link), the session is dropped and the socket marked + * dead, and both blocked-direction markers are cleared. */ +static void test_sendv_fatal(void) +{ + struct Client *c = fix(); + unsigned int out; + + enq(c, "M1", 0); + enq(c, "M2", 0); + cli_tls_want_rd(c) = IRCD_TLS_WANT_WRITE; /* must be wiped by teardown */ + script_write(IO_SUCCESS, 4, IRCD_TLS_WANT_NONE); + script_write(IO_FAILURE, 0, IRCD_TLS_WANT_NONE); + + assert(sendv_and_consume(c, &out) == IO_FAILURE); + assert(out == 0); + assert(wire_is("M1\r\n")); /* first message did go out */ + assert(HasFlag(c, FLAG_DEADSOCKET)); + assert(cli_tls_want_rd(c) == IRCD_TLS_WANT_NONE); + assert(cli_tls_want_wr(c) == IRCD_TLS_WANT_NONE); + assert(drop_calls == 1); + printf("Passed: sendv fatal error teardown\n"); +} + +/* Fatal error while draining a parked remainder across calls. */ +static void test_sendv_fatal_on_rexmit(void) +{ + struct Client *c = fix(); + unsigned int out; + + enq(c, "ABCDEF", 0); /* 8 bytes */ + script_write(IO_SUCCESS, 3, IRCD_TLS_WANT_NONE); + script_write(IO_BLOCKED, 0, IRCD_TLS_WANT_WRITE); + assert(sendv_and_consume(c, &out) == IO_BLOCKED); + assert(out == 3); + + script_reset(); + script_write(IO_FAILURE, 0, IRCD_TLS_WANT_NONE); + assert(sendv_and_consume(c, &out) == IO_FAILURE); + assert(out == 0); + assert(HasFlag(c, FLAG_DEADSOCKET)); + assert(drop_calls == 1); + printf("Passed: sendv fatal error during rexmit drain\n"); +} + +/* --- C: tls_io_recv blocked-direction recording --------------------------- */ + +static void test_recv_directions(void) +{ + struct Client *c = fix(); + char buf[64]; + unsigned int n = 0; + + /* success clears the marker */ + cli_tls_want_rd(c) = IRCD_TLS_WANT_WRITE; + rstep.io = IO_SUCCESS; + rstep.data = "PONG\r\n"; + assert(tls_io_recv(c, buf, sizeof(buf), &n) == IO_SUCCESS); + assert(n == 6 && !memcmp(buf, "PONG\r\n", 6)); + assert(cli_tls_want_rd(c) == IRCD_TLS_WANT_NONE); + + /* a read waiting to WRITE must assert writable with an empty sendq */ + rstep.io = IO_BLOCKED; + rstep.want = IRCD_TLS_WANT_WRITE; + assert(tls_io_recv(c, buf, sizeof(buf), &n) == IO_BLOCKED); + assert(cli_tls_want_rd(c) == IRCD_TLS_WANT_WRITE); + assert(MsgQLength(&cli_sendQ(c)) == 0); + assert(tls_desired_events(c) == (SOCK_EVENT_READABLE | SOCK_EVENT_WRITABLE)); + + /* a read waiting on its own direction wants readable only */ + rstep.want = IRCD_TLS_WANT_READ; + assert(tls_io_recv(c, buf, sizeof(buf), &n) == IO_BLOCKED); + assert(cli_tls_want_rd(c) == IRCD_TLS_WANT_READ); + assert(tls_desired_events(c) == SOCK_EVENT_READABLE); + + /* fatal: teardown, marker wipe, session dropped */ + cli_tls_want_wr(c) = IRCD_TLS_WANT_READ; + rstep.io = IO_FAILURE; + assert(tls_io_recv(c, buf, sizeof(buf), &n) == IO_FAILURE); + assert(HasFlag(c, FLAG_DEADSOCKET)); + assert(cli_tls_want_rd(c) == IRCD_TLS_WANT_NONE); + assert(cli_tls_want_wr(c) == IRCD_TLS_WANT_NONE); + assert(drop_calls == 1); + printf("Passed: recv direction recording and fatal teardown\n"); +} + +/* --- D: fingerprint storage ----------------------------------------------- */ + +static const char zeros[65]; + +static void test_fingerprint_storage(void) +{ + struct Client *c = fix(); + unsigned char digest[32]; + char expect[65]; + unsigned int i; + + for (i = 0; i < 32; ++i) { + digest[i] = (unsigned char)(i * 7 + 3); + sprintf(expect + i * 2, "%02x", digest[i]); + } + expect[64] = '\0'; + + tls_io_store_fingerprint(c, digest, 32); + assert(!strcmp(cli_tls_fingerprint(c), expect)); + + /* a non-SHA-256 digest length clears the slot */ + tls_io_store_fingerprint(c, digest, 20); + assert(!memcmp(cli_tls_fingerprint(c), zeros, 65)); + + /* Cloudflare ports suppress fingerprints entirely */ + tls_io_store_fingerprint(c, digest, 32); + FlagSet(&lst.flags, LISTEN_CLOUDFLARE); + con_listener(&conn) = &lst; + tls_io_store_fingerprint(c, digest, 32); + assert(!memcmp(cli_tls_fingerprint(c), zeros, 65)); + con_listener(&conn) = NULL; + FlagClr(&lst.flags, LISTEN_CLOUDFLARE); + + /* pre-formatted hex: copied; NULL / empty / over-long cleared */ + tls_io_store_fingerprint_hex(c, "abc123"); + assert(!strcmp(cli_tls_fingerprint(c), "abc123")); + tls_io_store_fingerprint_hex(c, NULL); + assert(!memcmp(cli_tls_fingerprint(c), zeros, 65)); + tls_io_store_fingerprint_hex(c, "abc123"); + tls_io_store_fingerprint_hex(c, ""); + assert(!memcmp(cli_tls_fingerprint(c), zeros, 65)); + tls_io_store_fingerprint_hex(c, expect); /* exactly 64: kept */ + assert(!strcmp(cli_tls_fingerprint(c), expect)); + { + char toolong[80]; + memset(toolong, 'a', sizeof(toolong) - 1); + toolong[sizeof(toolong) - 1] = '\0'; + tls_io_store_fingerprint_hex(c, toolong); /* 79 chars: cleared */ + assert(!memcmp(cli_tls_fingerprint(c), zeros, 65)); + } + printf("Passed: fingerprint storage edge cases\n"); +} + +/* --- E: negotiate trust policy over scripted peer material ----------------- */ + +/** Fresh fixture with a live dummy session mid-handshake. */ +static struct Client *fix_negotiating(void) +{ + struct Client *c = fix(); + + s_tls(&cli_socket(c)) = (void *)&lst; /* any non-NULL session */ + SetNegotiatingTLS(c); + return c; +} + +static void test_negotiate_policy(void) +{ + char reason[TLS_REASON_LEN]; + enum ircd_tls_want want; + struct Client *c; + + /* no session left: fail, never report success (or start_auth would loop) */ + c = fix_negotiating(); + s_tls(&cli_socket(c)) = NULL; + strcpy(reason, "STALE"); + assert(ircd_tls_negotiate(c, reason, sizeof(reason), &want) == -1); + assert(!strcmp(reason, "TLS setup failed (no session)")); + assert(!IsNegotiatingTLS(c)); + + /* in progress: report the blocked direction, reason stays empty */ + c = fix_negotiating(); + hstep.io = IO_BLOCKED; + hstep.want = IRCD_TLS_WANT_WRITE; + strcpy(reason, "STALE"); + assert(ircd_tls_negotiate(c, reason, sizeof(reason), &want) == 0); + assert(want == IRCD_TLS_WANT_WRITE); + assert(reason[0] == '\0'); + assert(IsNegotiatingTLS(c)); + + /* backend failure: its reason survives to the caller */ + c = fix_negotiating(); + hstep.io = IO_FAILURE; + hstep.reason = "handshake exploded"; + assert(ircd_tls_negotiate(c, reason, sizeof(reason), &want) == -1); + assert(!strcmp(reason, "handshake exploded")); + + /* cert required, none presented */ + c = fix_negotiating(); + fake_cert_required = 1; + hstep.io = IO_SUCCESS; + assert(ircd_tls_negotiate(c, reason, sizeof(reason), &want) == -1); + assert(strstr(reason, "no peer certificate") != NULL); + + /* cert presented but required only softly: PKIX advisory, accepted */ + c = fix_negotiating(); + fake_cert_required = 1; + hstep.io = IO_SUCCESS; + hstep.peer.have_cert = 1; + hstep.peer.verified = 0; + assert(ircd_tls_negotiate(c, reason, sizeof(reason), &want) == 1); + assert(!IsNegotiatingTLS(c)); + + /* verifypeer: unverified cert rejected with the backend's reason */ + c = fix_negotiating(); + fake_verifypeer = 1; + hstep.io = IO_SUCCESS; + hstep.peer.have_cert = 1; + hstep.peer.verified = 0; + strcpy(hstep.peer.verify_err, "self signed certificate"); + assert(ircd_tls_negotiate(c, reason, sizeof(reason), &want) == -1); + assert(!strcmp(reason, "self signed certificate")); + + /* verifypeer: unverified with no backend detail gets the generic reason */ + c = fix_negotiating(); + fake_verifypeer = 1; + hstep.io = IO_SUCCESS; + hstep.peer.have_cert = 1; + assert(ircd_tls_negotiate(c, reason, sizeof(reason), &want) == -1); + assert(!strcmp(reason, "certificate verification failed")); + + /* verifypeer: verified cert accepted, raw digest becomes the fingerprint */ + c = fix_negotiating(); + fake_verifypeer = 1; + hstep.io = IO_SUCCESS; + hstep.peer.have_cert = 1; + hstep.peer.verified = 1; + memset(hstep.peer.digest, 0xab, 32); + hstep.peer.digest_len = 32; + assert(ircd_tls_negotiate(c, reason, sizeof(reason), &want) == 1); + assert(!IsNegotiatingTLS(c)); + { + char expect[65]; + int i; + for (i = 0; i < 32; ++i) + sprintf(expect + i * 2, "%02x", 0xab); + expect[64] = '\0'; + assert(!strcmp(cli_tls_fingerprint(c), expect)); + } + + /* no raw digest: the backend's pre-formatted hex is used (libtls) */ + c = fix_negotiating(); + hstep.io = IO_SUCCESS; + hstep.peer.have_cert = 1; + strcpy(hstep.peer.fp_hex, "deadbeef"); + assert(ircd_tls_negotiate(c, reason, sizeof(reason), &want) == 1); + assert(!strcmp(cli_tls_fingerprint(c), "deadbeef")); + + /* neither digest nor hex: fingerprint slot ends up cleared */ + c = fix_negotiating(); + hstep.io = IO_SUCCESS; + hstep.peer.have_cert = 1; + assert(ircd_tls_negotiate(c, reason, sizeof(reason), &want) == 1); + assert(!memcmp(cli_tls_fingerprint(c), zeros, 65)); + + /* no cert at all with everything off: plain accept (user TLS port) */ + c = fix_negotiating(); + hstep.io = IO_SUCCESS; + assert(ircd_tls_negotiate(c, reason, sizeof(reason), &want) == 1); + assert(!IsNegotiatingTLS(c)); + + printf("Passed: negotiate trust policy matrix\n"); +} + +int +main(int argc, char *argv[]) +{ + (void)argc; + (void)argv; + + msgq_init(&con_sendQ(&conn)); /* so the first fix()'s MsgQClear is safe */ + + test_interest_truth_table(); + + test_sendv_clean_write(); + test_sendv_short_write_drained_in_call(); + test_sendv_block_resume_zero_credit(); + test_sendv_rexmit_prio_jump(); + test_sendv_prio_transmits_first(); + test_sendv_three_way_order(); + test_sendv_fatal(); + test_sendv_fatal_on_rexmit(); + + test_recv_directions(); + + test_fingerprint_storage(); + + test_negotiate_policy(); + + printf("All tls_io tests passed.\n"); + return 0; +}