Commit 6b3f51aebb for bind

commit 6b3f51aebbb60b2a7bd5bfe49d45eeea29305889
Author: Aydın Mercan <aydin@isc.org>
Date:   Thu Aug 27 12:21:16 2026 +0300

    Add the required TLS plumbing for QUIC connections

    The TLS usage inside QUIC connections require coordination from the TLS
    stack. While ngtcp2 provides relevant `libngtcp2-crypto-*` libaries for
    this, they are not used due to a general lack of packaging across
    distributions.

    Currently, there are two ways of doing this for BIND:
    - Relevant `OSSL_DISPATCH` functions added in 3.5
    - The `SSL_QUIC_METHOD` vtable created by BoringSSL and adopted by other
      OpenSSL forks.

    Supporting one of the two interfaces is a hard requirement for QUIC.

diff --git a/lib/isc/include/isc/quic.h b/lib/isc/include/isc/quic.h
index 7c45baecdc..b189d382cd 100644
--- a/lib/isc/include/isc/quic.h
+++ b/lib/isc/include/isc/quic.h
@@ -70,13 +70,106 @@ typedef enum isc_quic_version {
 	ISC_QUIC_VERSION__MAX = 4,
 } isc_quic_version_t;

+typedef enum isc_quic_application_error_kind {
+	/**
+	 * \brief
+	 * Indeterminate application error status.
+	 *
+	 * It is safe to trigger an contract failure upon receiving this value.
+	 */
+	ISC_QUIC_APPLICATION_ERROR_INVALID = 0,
+
+	/**
+	 * \brief
+	 * No application error code is set.
+	 */
+	ISC_QUIC_APPLICATION_ERROR_NONE = 1,
+
+	/**
+	 * \brief
+	 * The linked ngtcp2 is too old to determine which side has set the
+	 * application error code.
+	 */
+	ISC_QUIC_APPLICATION_ERROR_UNKNOWN = 2,
+
+	ISC_QUIC_APPLICATION_ERROR_RX_ONLY = 3,
+	ISC_QUIC_APPLICATION_ERROR_TX_ONLY = 4,
+	ISC_QUIC_APPLICATION_ERROR_RX_AND_TX = 5,
+	ISC_QUIC_APPLICATION_ERROR__MAX = 6,
+} isc_quic_application_error_kind_t;
+
 /**
  * \brief
  * User-specified callbacks
  *
  * All callbacks are optional.
  */
-struct isc_quic_conn_callbacks {};
+struct isc_quic_conn_callbacks {
+	/**
+	 * \brief
+	 * Callback function for when handshake has been completed
+	 */
+	void (*handshake_completed)(void *cbarg);
+
+	/**
+	 * \brief
+	 * Callback function for when the remote endpoint has opened a new
+	 * stream.
+	 */
+	isc_result_t (*stream_opened)(isc_quic_conn_t *conn, void *cbarg,
+				      int64_t stream_id);
+
+	/**
+	 * \brief
+	 * Callback function for when a stream has been closed.
+	 *
+	 * If the application error kind is unknown, both RX and TX application
+	 * errors codes will be set as the unknown side's error code.
+	 *
+	 * Returning anything other than `ISC_R_SUCCESS` will cause the
+	 * connection to fail and require closing.
+	 */
+	isc_result_t (*stream_closed)(isc_quic_conn_t *conn, void *cbarg,
+				      int64_t stream_id,
+				      isc_quic_application_error_kind_t kind,
+				      uint64_t rx_application_error_code,
+				      uint64_t tx_application_error_code);
+
+	/**
+	 * \brief
+	 * Callback function for when there is data to be read in a stream.
+	 *
+	 * Called by isc_quic_conn_push_packet when there is data to be read
+	 * in a stream.
+	 *
+	 * Returning anything other than `ISC_R_SUCCESS` will cause the
+	 * connection to fail and require closing.
+	 */
+	isc_result_t (*data_read)(isc_quic_conn_t *conn, void *cbarg,
+				  isc_quic_stream_data_info_t info,
+				  isc_constregion_t	      data);
+};
+
+struct isc_quic_stream_data_info {
+	/**
+	 * \brief
+	 * True if this is the final packet that will be received from this
+	 * stream.
+	 */
+	bool final;
+
+	/**
+	 * \brief
+	 * True if the data is trasmitted during 0-RTT.
+	 */
+	bool zerortt;
+
+	/**
+	 * \brief
+	 * Stream ID of received data.
+	 */
+	int64_t stream_id;
+};

 /**
  * \brief
@@ -385,3 +478,34 @@ isc_quic_conn_server_create(
  */

 ISC_REFCOUNT_DECL(isc_quic_conn);
+
+isc_result_t
+isc_quic_tlsctx_client_configure(isc_tlsctx_t *tlsctx);
+/**<
+ * \brief
+ * Configure the client TLS context to be used for QUIC connections.
+ *
+ * \retval ISC_R_SUCCESS on success
+ * \retval ISC_R_TLSERROR on failure
+ */
+
+isc_result_t
+isc_quic_tlsctx_server_configure(isc_tlsctx_t *tlsctx);
+/**<
+ * \brief
+ * Configure the server TLS context to be used for QUIC connections.
+ *
+ * \retval ISC_R_SUCCESS on success
+ * \retval ISC_R_TLSERROR on failure
+ */
+
+#ifdef HAVE_LIBNGTCP2
+void
+isc__quic_initialize(void);
+
+void
+isc__quic_shutdown(void);
+#else /* HAVE_LIBNGTCP2 */
+#define isc__quic_initialize()
+#define isc__quic_shutdown()
+#endif /* HAVE_LIBNGTCP2 */
diff --git a/lib/isc/include/isc/types.h b/lib/isc/include/isc/types.h
index fcbe1e4092..72c3757f02 100644
--- a/lib/isc/include/isc/types.h
+++ b/lib/isc/include/isc/types.h
@@ -79,11 +79,10 @@ typedef struct isc_timer      isc_timer_t;	/*%< Timer */
 typedef struct isc_work	      isc_work_t;	/*%< Work offloaded to an
 						 *   external thread */

-#ifdef HAVE_LIBNGTCP2
-typedef struct isc_quic_conn	       isc_quic_conn_t;
-typedef struct isc_quic_conn_callbacks isc_quic_conn_callbacks_t;
-typedef struct isc_quic_conn_options   isc_quic_conn_options_t;
-#endif /* HAVE_LIBNGTCP2 */
+typedef struct isc_quic_conn		 isc_quic_conn_t;
+typedef struct isc_quic_conn_options	 isc_quic_conn_options_t;
+typedef struct isc_quic_conn_callbacks	 isc_quic_conn_callbacks_t;
+typedef struct isc_quic_stream_data_info isc_quic_stream_data_info_t;

 #if HAVE_LIBNGHTTP2
 typedef struct isc_nm_http_endpoints isc_nm_http_endpoints_t;
diff --git a/lib/isc/lib.c b/lib/isc/lib.c
index c0f9d30dba..f98db0a79d 100644
--- a/lib/isc/lib.c
+++ b/lib/isc/lib.c
@@ -19,6 +19,7 @@
 #include <isc/md.h>
 #include <isc/mem.h>
 #include <isc/os.h>
+#include <isc/quic.h>
 #include <isc/refcount.h>
 #include <isc/tls.h>
 #include <isc/urcu.h>
@@ -54,6 +55,7 @@ isc__lib_initialize(void) {
 	isc__mem_initialize();
 	isc__log_initialize();
 	isc__crypto_initialize();
+	isc__quic_initialize();
 	isc__uv_initialize();
 	isc__xml_initialize();
 	isc__hash_initialize();
@@ -73,6 +75,7 @@ isc__lib_shutdown(void) {
 	isc__iterated_hash_shutdown();
 	isc__xml_shutdown();
 	isc__uv_shutdown();
+	isc__quic_shutdown();
 	isc__crypto_shutdown();
 	isc__log_shutdown();
 	isc__mem_shutdown();
diff --git a/lib/isc/quic/boringlike.c b/lib/isc/quic/boringlike.c
new file mode 100644
index 0000000000..49da236539
--- /dev/null
+++ b/lib/isc/quic/boringlike.c
@@ -0,0 +1,373 @@
+/*
+ * Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+ *
+ * SPDX-License-Identifier: MPL-2.0
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * See the COPYRIGHT file distributed with this work for additional
+ * information regarding copyright ownership.
+ */
+
+#include <stdint.h>
+
+#include <ngtcp2/ngtcp2.h>
+#include <openssl/crypto.h>
+#include <openssl/err.h>
+#include <openssl/opensslv.h>
+#include <openssl/ssl.h>
+
+#if __has_include(<openssl/nid.h>)
+#include <openssl/nid.h>
+#endif
+
+#include <isc/crypto.h>
+#include <isc/ossl_wrap.h>
+#include <isc/quic.h>
+#include <isc/util.h>
+
+#include "quic_p.h"
+
+static int quic_index = -1;
+
+static int
+set_read_secret_cb(isc_tls_t *tls, enum ssl_encryption_level_t level,
+		   const SSL_CIPHER *cipher, const uint8_t *secret, size_t len);
+
+static int
+set_write_secret_cb(isc_tls_t *tls, enum ssl_encryption_level_t level,
+		    const SSL_CIPHER *cipher, const uint8_t *secret,
+		    size_t len);
+
+static int
+add_handshake_data_cb(isc_tls_t *tls, enum ssl_encryption_level_t level,
+		      const uint8_t *data, size_t len);
+
+static int
+flush_flight_cb(isc_tls_t *tls);
+
+static int
+send_alert_cb(isc_tls_t *ssl, enum ssl_encryption_level_t level, uint8_t alert);
+
+static SSL_QUIC_METHOD method = {
+	.set_read_secret = set_read_secret_cb,
+	.set_write_secret = set_write_secret_cb,
+	.add_handshake_data = add_handshake_data_cb,
+	.flush_flight = flush_flight_cb,
+	.send_alert = send_alert_cb,
+};
+
+/**
+ * \brief
+ * Lookup table for translating libssl encryption levels to its ngtcp2
+ * counterpart.
+ */
+static ngtcp2_encryption_level libssl_to_ngtcp2_level_lut[] = {
+	[ssl_encryption_initial] = NGTCP2_ENCRYPTION_LEVEL_INITIAL,
+	[ssl_encryption_early_data] = NGTCP2_ENCRYPTION_LEVEL_0RTT,
+	[ssl_encryption_handshake] = NGTCP2_ENCRYPTION_LEVEL_HANDSHAKE,
+	[ssl_encryption_application] = NGTCP2_ENCRYPTION_LEVEL_1RTT,
+};
+
+/**
+ * \brief
+ * Lookup table for translating ngtcp2 encryption levels to its libssl
+ * counterpart.
+ */
+static enum ssl_encryption_level_t libngtcp2_to_ssl_level_lut[] = {
+	[NGTCP2_ENCRYPTION_LEVEL_INITIAL] = ssl_encryption_initial,
+	[NGTCP2_ENCRYPTION_LEVEL_0RTT] = ssl_encryption_early_data,
+	[NGTCP2_ENCRYPTION_LEVEL_HANDSHAKE] = ssl_encryption_handshake,
+	[NGTCP2_ENCRYPTION_LEVEL_1RTT] = ssl_encryption_application,
+};
+
+static int
+set_read_secret_cb(isc_tls_t *tls, enum ssl_encryption_level_t level,
+		   const SSL_CIPHER *cipher ISC_ATTR_UNUSED,
+		   const uint8_t *secret, size_t len) {
+	isc_quic_conn_t *conn = SSL_get_ex_data(tls, quic_index);
+	bool is_server;
+
+#if NGTCP2_VERSION_NUM >= 0x011700 /* 1.23.0 */
+	is_server = ngtcp2_conn_is_server2(conn->inner);
+#else  /* NGTCP2_VERSION_NUM >= 0x011700 */
+	is_server = ngtcp2_conn_is_server(conn->inner);
+#endif /* NGTCP2_VERSION_NUM >= 0x011700 */
+
+	if (isc__quic_setup_read_key(
+		    conn, is_server, libssl_to_ngtcp2_level_lut[level],
+		    (isc_constregion_t){ len, secret }) != ISC_R_SUCCESS)
+	{
+		return 0;
+	}
+
+	return 1;
+}
+
+static int
+set_write_secret_cb(isc_tls_t *tls, enum ssl_encryption_level_t level,
+		    const SSL_CIPHER *cipher ISC_ATTR_UNUSED,
+		    const uint8_t *secret, size_t len) {
+	isc_quic_conn_t *conn = SSL_get_ex_data(tls, quic_index);
+	bool is_server;
+
+#if NGTCP2_VERSION_NUM >= 0x011700 /* 1.23.0 */
+	is_server = ngtcp2_conn_is_server2(conn->inner);
+#else  /* NGTCP2_VERSION_NUM >= 0x011700 */
+	is_server = ngtcp2_conn_is_server(conn->inner);
+#endif /* NGTCP2_VERSION_NUM >= 0x011700 */
+
+	if (isc__quic_setup_write_key(
+		    conn, is_server, libssl_to_ngtcp2_level_lut[level],
+		    (isc_constregion_t){ len, secret }) != ISC_R_SUCCESS)
+	{
+		return 0;
+	}
+
+	return 1;
+}
+
+static int
+add_handshake_data_cb(isc_tls_t *tls, enum ssl_encryption_level_t level,
+		      const uint8_t *data, size_t len) {
+	isc_quic_conn_t *conn;
+	int r;
+
+	REQUIRE(level < ARRAY_SIZE(libssl_to_ngtcp2_level_lut));
+
+	conn = SSL_get_ex_data(tls, quic_index);
+
+	r = ngtcp2_conn_submit_crypto_data(
+		conn->inner, libssl_to_ngtcp2_level_lut[level], data, len);
+	if (r != 0) {
+		ngtcp2_conn_set_tls_error(conn->inner, r);
+		return 0;
+	}
+
+	return 1;
+}
+
+static int
+flush_flight_cb(isc_tls_t *tls ISC_ATTR_UNUSED) {
+	return 1;
+}
+
+static int
+send_alert_cb(isc_tls_t *tls, enum ssl_encryption_level_t level ISC_ATTR_UNUSED,
+	      uint8_t alert) {
+	isc_quic_conn_t *conn = SSL_get_ex_data(tls, quic_index);
+	ngtcp2_conn_set_tls_alert(conn->inner, alert);
+	return 1;
+}
+
+static int
+quic_alpn_select(isc_tls_t *tls, const unsigned char **outp,
+		 unsigned char *lenp, const unsigned char *in, unsigned int len,
+		 void *arg ISC_ATTR_UNUSED) {
+	isc_quic_conn_t *conn = SSL_get_ex_data(tls, quic_index);
+	INSIST(conn != NULL);
+
+	int r = SSL_select_next_proto(UNCONST(outp), lenp, in, len,
+				      conn->alpn.data, conn->alpn.len);
+	if (r != OPENSSL_NPN_NEGOTIATED) {
+		return SSL_TLSEXT_ERR_ALERT_FATAL;
+	}
+
+	return SSL_TLSEXT_ERR_OK;
+}
+
+isc_result_t
+isc__quic_set_remote_transport_params(isc_quic_conn_t *conn, isc_tls_t *tls) {
+	const uint8_t *out_params;
+	size_t out_params_len;
+	int r;
+
+	SSL_get_peer_quic_transport_params(tls, &out_params, &out_params_len);
+
+	r = ngtcp2_conn_decode_and_set_remote_transport_params(
+		conn->inner, out_params, out_params_len);
+	if (r != 0) {
+		ngtcp2_conn_set_tls_error(conn->inner, r);
+		return ISC_R_TLSERROR;
+	}
+
+	return ISC_R_SUCCESS;
+}
+
+isc_result_t
+isc__quic_set_local_transport_params(isc_quic_conn_t *conn ISC_ATTR_UNUSED,
+				     isc_tls_t *tls) {
+	uint8_t buffer[512];
+	ngtcp2_ssize len;
+	int r;
+
+#if NGTCP2_VERSION_NUM >= 0x011700 /* 1.23.0 */
+	len = ngtcp2_conn_encode_local_transport_params2(conn->inner, buffer,
+							 sizeof(buffer));
+#else  /* NGTCP2_VERSION_NUM >= 0x011700 */
+	len = ngtcp2_conn_encode_local_transport_params(conn->inner, buffer,
+							sizeof(buffer));
+#endif /* NGTCP2_VERSION_NUM >= 0x011700 */
+	if (len < 0) {
+		return ISC_R_NOSPACE;
+	}
+
+	r = SSL_set_quic_transport_params(tls, buffer, len);
+	if (r != 1) {
+		return isc_ossl_wrap_logged_toresult(
+			ISC_LOGCATEGORY_GENERAL, ISC_LOGMODULE_CRYPTO,
+			"SSL_set_quic_transport_params", ISC_R_TLSERROR);
+	}
+
+	return ISC_R_SUCCESS;
+}
+
+isc_result_t
+isc__quic_setup_tls(isc_tls_t *tls, isc_quic_conn_t *conn) {
+	SSL_set_ex_data(tls, quic_index, conn);
+
+	return ISC_R_SUCCESS;
+}
+
+isc_result_t
+isc__quic_do_tls(isc_quic_conn_t *conn, isc_tls_t *tls,
+		 ngtcp2_encryption_level nglevel, isc_constregion_t data) {
+	enum ssl_encryption_level_t level;
+	isc_result_t result;
+	ngtcp2_conn *ngconn;
+	int r;
+
+	REQUIRE(nglevel < ARRAY_SIZE(libngtcp2_to_ssl_level_lut));
+
+	level = libngtcp2_to_ssl_level_lut[nglevel];
+	ngconn = conn->inner;
+
+	ERR_set_mark();
+
+	if (data.length != 0 &&
+	    SSL_provide_quic_data(tls, level, data.base, data.length) != 1)
+	{
+		CLEANUP(ISC_R_TLSERROR);
+	}
+
+#if NGTCP2_VERSION_NUM >= 0x011700 /* 1.23.0 */
+	while (ngtcp2_conn_get_handshake_completed2(ngconn) == 0) {
+#else  /* NGTCP2_VERSION_NUM >= 0x011700 */
+	while (ngtcp2_conn_get_handshake_completed(ngconn) == 0) {
+#endif /* NGTCP2_VERSION_NUM >= 0x011700 */
+		r = SSL_do_handshake(tls);
+		if (r != 1) {
+			switch (SSL_get_error(tls, r)) {
+			case SSL_ERROR_WANT_READ:
+			case SSL_ERROR_WANT_WRITE:
+			case SSL_ERROR_WANT_X509_LOOKUP:
+#ifdef LIBRESSL_VERSION_NUMBER
+			case SSL_ERROR_WANT_CLIENT_HELLO_CB:
+				CLEANUP(ISC_R_SUCCESS);
+#else
+			case SSL_ERROR_WANT_PRIVATE_KEY_OPERATION:
+			case SSL_ERROR_WANT_CERTIFICATE_VERIFY:
+				CLEANUP(ISC_R_SUCCESS);
+			case SSL_ERROR_EARLY_DATA_REJECTED:
+#if NGTCP2_VERSION_NUM >= 0x011700 /* 1.23.0 */
+				INSIST(!ngtcp2_conn_is_server2(ngconn));
+#else
+				INSIST(!ngtcp2_conn_is_server(ngconn));
+#endif /* NGTCP2_VERSION_NUM >= 0x011700 */
+				SSL_reset_early_data_reject(tls);
+				if (ngtcp2_conn_tls_early_data_rejected(
+					    ngconn) != 0)
+				{
+					CLEANUP(ISC_R_FAILURE);
+				}
+				continue;
+#endif /* LIBRESSL_VERSION_NUMBER */
+			default:
+				CLEANUP(ISC_R_TLSERROR);
+			}
+		}
+
+#ifndef LIBRESSL_VERSION_NUMBER
+		if (SSL_in_early_data(tls) == 1) {
+			CLEANUP(ISC_R_SUCCESS);
+		}
+#endif /* LIBRESSL_VERSION_NUMBER */
+
+		ngtcp2_conn_tls_handshake_completed(ngconn);
+
+		break;
+	}
+
+	r = SSL_process_quic_post_handshake(tls);
+	if (r != 1) {
+		switch (SSL_get_error(tls, r)) {
+		case SSL_ERROR_WANT_READ:
+		case SSL_ERROR_WANT_WRITE:
+			break;
+		default:
+			CLEANUP(ISC_R_TLSERROR);
+		}
+	}
+
+	result = ISC_R_SUCCESS;
+
+cleanup:
+	ERR_pop_to_mark();
+	return result;
+}
+
+isc_result_t
+isc_quic_tlsctx_client_configure(isc_tlsctx_t *tlsctx) {
+	REQUIRE(tlsctx != NULL);
+
+	/*
+	 * Specified in RFC 9001, Section 4.2.
+	 */
+	if (SSL_CTX_set_min_proto_version(tlsctx, TLS1_3_VERSION) != 1) {
+		return isc_ossl_wrap_logged_toresult(
+			ISC_LOGCATEGORY_GENERAL, ISC_LOGMODULE_CRYPTO,
+			"SSL_CTX_set_min_proto_version", ISC_R_TLSERROR);
+	}
+
+	if (SSL_CTX_set_quic_method(tlsctx, &method) != 1) {
+		return isc_ossl_wrap_logged_toresult(
+			ISC_LOGCATEGORY_GENERAL, ISC_LOGMODULE_CRYPTO,
+			"SSL_CTX_set_quic_method", ISC_R_TLSERROR);
+	}
+
+	return ISC_R_SUCCESS;
+}
+
+isc_result_t
+isc_quic_tlsctx_server_configure(isc_tlsctx_t *tlsctx) {
+	REQUIRE(tlsctx != NULL);
+
+	SSL_CTX_set_alpn_select_cb(tlsctx, quic_alpn_select, NULL);
+
+	if (SSL_CTX_set_min_proto_version(tlsctx, TLS1_3_VERSION) != 1) {
+		return isc_ossl_wrap_logged_toresult(
+			ISC_LOGCATEGORY_GENERAL, ISC_LOGMODULE_CRYPTO,
+			"SSL_CTX_set_min_proto_version", ISC_R_TLSERROR);
+	}
+
+	if (SSL_CTX_set_quic_method(tlsctx, &method) != 1) {
+		return isc_ossl_wrap_logged_toresult(
+			ISC_LOGCATEGORY_GENERAL, ISC_LOGMODULE_CRYPTO,
+			"SSL_CTX_set_quic_method", ISC_R_TLSERROR);
+	}
+
+	return ISC_R_SUCCESS;
+}
+
+void
+isc__quic_initialize(void) {
+	quic_index = SSL_get_ex_new_index(0, NULL, NULL, NULL, NULL);
+	INSIST(quic_index != -1);
+}
+
+void
+isc__quic_shutdown(void) {
+	/* noop */
+}
diff --git a/lib/isc/quic/connection.c b/lib/isc/quic/connection.c
index 8dd4e9de95..935b26070c 100644
--- a/lib/isc/quic/connection.c
+++ b/lib/isc/quic/connection.c
@@ -14,21 +14,500 @@
 #include <stdint.h>

 #include <ngtcp2/ngtcp2.h>
+#include <openssl/err.h>
+#include <openssl/ssl.h>

 #include <isc/attributes.h>
+#include <isc/crypto.h>
 #include <isc/magic.h>
 #include <isc/mem.h>
+#include <isc/random.h>
+#include <isc/safe.h>
 #include <isc/util.h>

 #include "quic_p.h" /* IWYU pragma: keep */

+/**
+ * \brief
+ * Specified in RFC9001, Section 5.2.
+ */
+#define QUIC_INITIAL_SECRET_LENGTH 32
+
+#define QUIC_AES128_KEY_LENGTH 16
+
 #define VALID_CONNECTION(c) ISC_MAGIC_VALID(c, conn_magic)

+#ifdef ISC_QUIC_STATE_CHECK
+#define CHECK_STATE(conn, ...)                                         \
+	({                                                             \
+		__label__ out;                                         \
+		isc__quic_conn_state_t _allowed[] = { __VA_ARGS__ };   \
+		for (size_t _i = 0; _i < ARRAY_SIZE(_allowed); _i++) { \
+			if ((conn)->state == _allowed[_i]) {           \
+				goto out;                              \
+			}                                              \
+		}                                                      \
+		UNREACHABLE();                                         \
+	out:;                                                          \
+	})
+#else /* ISC_QUIC_STATE_CHECK */
+#define CHECK_STATE(conn, ...)
+#endif /* ISC_QUIC_STATE_CHECK */
+
+struct isc__quic_stream {
+	int64_t id;
+	size_t last_acked_offset;
+	ISC_LIST(isc__quic_stream_data_t) ack_data;
+	ISC_LINK(isc__quic_stream_t) link;
+};
+
+struct isc__quic_stream_data {
+	bool finish;
+	bool zerortt;
+	int64_t stream_id;
+	size_t length;
+	ISC_LINK(isc__quic_stream_data_t) link;
+	uint8_t bytes[] ISC_ATTR_COUNTED_BY(length);
+};
+
 constexpr uint32_t conn_magic = ISC_MAGIC('Q', 'U', 'I', 'c');

+/**
+ * \brief
+ * The confidentiality limit for AES-128-GCM and AES-256-GCM as used in TLS.
+ *
+ * The value is specified in RFC9001, Section 6.6.
+ */
+constexpr uint64_t aes_gcm_max_encryption = 8388608;
+
+/**
+ * \brief
+ * The integrity limit for AES-128-GCM and AES-256-GCM as used in TLS.
+ *
+ * The value is specified in RFC9001, Section 6.6.
+ */
+constexpr uint64_t aes_gcm_max_decryption_failure = 4503599627370496;
+
+/**
+ * \brief
+ * The confidentiality limit for ChaCha20-Poly1305 as used in TLS.
+ *
+ * The value is specified in RFC9001, Section 6.6.
+ */
+constexpr uint64_t chacha20poly1305_max_encryption = 4611686018427387904;
+
+/**
+ * \brief
+ * The integrity limit for ChaCha20-Poly1305 as used in TLS.
+ *
+ * The value is specified in RFC9001, Section 6.6.
+ */
+constexpr uint64_t chacha20poly1305_max_decryption_failure = 68719476736;
+
+static int
+client_initial_cb(ngtcp2_conn *ngconn, void *user_data);
+
+static int
+recv_client_initial_cb(ngtcp2_conn *ngconn, const ngtcp2_cid *dcid,
+		       void *user_data);
+
+static int
+recv_crypto_data_cb(ngtcp2_conn *ngconn, ngtcp2_encryption_level nglevel,
+		    uint64_t offset, const uint8_t *data, size_t len,
+		    void *user_data);
+
+static int
+handshake_completed_cb(ngtcp2_conn *ngconn, void *user_data);
+
+static int
+encrypt_cb(uint8_t *dest, const ngtcp2_crypto_aead *ngaead,
+	   const ngtcp2_crypto_aead_ctx *ngctx, const uint8_t *plaintext,
+	   size_t plaintextlen, const uint8_t *nonce, size_t noncelen,
+	   const uint8_t *aad, size_t aadlen);
+
+static int
+decrypt_cb(uint8_t *out_ptr, const ngtcp2_crypto_aead *ngaead,
+	   const ngtcp2_crypto_aead_ctx *ngctx, const uint8_t *ciphertext_ptr,
+	   size_t ciphertext_len, const uint8_t *nonce_ptr, size_t nonce_len,
+	   const uint8_t *aad_ptr, size_t aad_len);
+
+static int
+hp_mask_cb(uint8_t *dest, const ngtcp2_crypto_cipher *nghp,
+	   const ngtcp2_crypto_cipher_ctx *ngctx, const uint8_t *sample);
+
+static int
+recv_stream_data_cb(ngtcp2_conn *ngconn, uint32_t flags, int64_t stream_id,
+		    uint64_t offset, const uint8_t *data, size_t datalen,
+		    void *user_data, void *stream_user_data);
+
+static int
+acked_stream_data_offset_cb(ngtcp2_conn *ngconn, int64_t stream_id,
+			    uint64_t offset, uint64_t datalen, void *user_data,
+			    void *stream_user_data);
+
+static int
+stream_open_cb(ngtcp2_conn *conn, int64_t stream_id, void *user_data);
+
+#ifndef NGTCP2_CALLBACKS_V5
+static int
+stream_close_cb(ngtcp2_conn *ngconn, uint32_t flags, int64_t stream_id,
+		uint64_t app_error_code, void *user_data,
+		void *stream_user_data);
+#endif /* !NGTCP2_CALLBACKS_V5 */
+
+static int
+recv_retry_cb(ngtcp2_conn *ngconn, const ngtcp2_pkt_hd *header,
+	      void *user_data);
+
+static void
+rand_cb(uint8_t *dest, size_t destlen, const ngtcp2_rand_ctx *rand_ctx);
+
+#ifndef NGTCP2_CALLBACKS_V3
+static int
+get_new_connection_id_cb(ngtcp2_conn *ngconn, ngtcp2_cid *cid, uint8_t *token,
+			 size_t cidlen, void *user_data);
+#endif /* !NGTCP2_CALLBACKS_V3 */
+
+static int
+remove_connection_id_cb(ngtcp2_conn *ngconn, const ngtcp2_cid *ngcid,
+			void *user_data);
+
+static int
+update_key_cb(ngtcp2_conn *ngconn, uint8_t *rx_secret, uint8_t *tx_secret,
+	      ngtcp2_crypto_aead_ctx *rx_aead_ctx, uint8_t *rx_iv,
+	      ngtcp2_crypto_aead_ctx *tx_aead_ctx, uint8_t *tx_iv,
+	      const uint8_t *current_rx_secret,
+	      const uint8_t *current_tx_secret, size_t secretlen,
+	      void *user_data);
+
+static void
+delete_crypto_aead_ctx_cb(ngtcp2_conn *ngconn, ngtcp2_crypto_aead_ctx *ngctx,
+			  void *user_data);
+
+static void
+delete_crypto_cipher_ctx_cb(ngtcp2_conn *ngconn,
+			    ngtcp2_crypto_cipher_ctx *ngctx, void *user_data);
+
+#ifndef NGTCP2_CALLBACKS_V3
+static int
+get_path_challenge_data_cb(ngtcp2_conn *ngconn, uint8_t *data, void *user_data);
+#endif /* NGTCP2_CALLBACKS_V3 */
+
+static int
+version_negotiation_cb(ngtcp2_conn *ngconn, uint32_t ngversion,
+		       const ngtcp2_cid *client_dcid, void *user_data);
+
+#ifdef NGTCP2_CALLBACKS_V3
+static int
+get_new_connection_id2_cb(ngtcp2_conn *ngconn, ngtcp2_cid *ngcid,
+			  ngtcp2_stateless_reset_token *token, size_t cidlen,
+			  void *user_data);
+
+static int
+dcid_status2_cb(ngtcp2_conn *ngconn, ngtcp2_connection_id_status_type type,
+		uint64_t seq, const ngtcp2_cid *ngcid,
+		const ngtcp2_stateless_reset_token *ngtoken, void *user_data);
+
+static int
+get_path_challenge_data2_cb(ngtcp2_conn *ngconn,
+			    ngtcp2_path_challenge_data *data, void *user_data);
+#endif /* NGTCP2_CALLBACKS_V3 */
+
+#ifdef NGTCP2_CALLBACKS_V4
+static int
+recv_stop_sending_cb(ngtcp2_conn *ngconn, int64_t stream_id,
+		     uint64_t app_error_code, void *user_data,
+		     void *stream_user_data);
+#endif /* NGTCP2_CALLBACKS_V4 */
+
+#ifdef NGTCP2_CALLBACKS_V5
+static int
+stream_close2_cb(ngtcp2_conn *ngconn, uint32_t flags, int64_t stream_id,
+		 uint64_t rx_app_error_code, uint64_t tx_app_error_code,
+		 void *user_data, void *stream_user_data);
+#endif /* NGTCP2_CALLBACKS_V5 */
+
+static const ngtcp2_callbacks client_cb = {
+	.client_initial = client_initial_cb,
+	.recv_crypto_data = recv_crypto_data_cb,
+	.handshake_completed = handshake_completed_cb,
+	.encrypt = encrypt_cb,
+	.decrypt = decrypt_cb,
+	.hp_mask = hp_mask_cb,
+	.recv_stream_data = recv_stream_data_cb,
+	.acked_stream_data_offset = acked_stream_data_offset_cb,
+	.stream_open = stream_open_cb,
+#ifndef NGTCP2_CALLBACKS_V5
+	.stream_close = stream_close_cb,
+#endif /* NGTCP2_CALLBACKS_V5 */
+	.recv_retry = recv_retry_cb,
+	.rand = rand_cb,
+#ifndef NGTCP2_CALLBACKS_V3
+	.get_new_connection_id = get_new_connection_id_cb,
+#endif /* NGTCP2_CALLBACKS_V3 */
+	.remove_connection_id = remove_connection_id_cb,
+	.update_key = update_key_cb,
+	.delete_crypto_aead_ctx = delete_crypto_aead_ctx_cb,
+	.delete_crypto_cipher_ctx = delete_crypto_cipher_ctx_cb,
+#ifndef NGTCP2_CALLBACKS_V3
+	.get_path_challenge_data = get_path_challenge_data_cb,
+#endif /* NGTCP2_CALLBACKS_V3 */
+	.version_negotiation = version_negotiation_cb,
+#ifdef NGTCP2_CALLBACKS_V3
+	.get_new_connection_id2 = get_new_connection_id2_cb,
+	.get_path_challenge_data2 = get_path_challenge_data2_cb,
+#endif /* NGTCP2_CALLBACKS_V3 */
+#ifdef NGTCP2_CALLBACKS_V4
+	.recv_stop_sending = recv_stop_sending_cb,
+#endif /* NGTCP2_CALLBACKS_V4 */
+#ifdef NGTCP2_CALLBACKS_V5
+	.stream_close2 = stream_close2_cb,
+#endif /* NGTCP2_CALLBACKS_V5 */
+};
+
+static const ngtcp2_callbacks server_cb = {
+	.recv_client_initial = recv_client_initial_cb,
+	.recv_crypto_data = recv_crypto_data_cb,
+	.handshake_completed = handshake_completed_cb,
+	.encrypt = encrypt_cb,
+	.decrypt = decrypt_cb,
+	.hp_mask = hp_mask_cb,
+	.recv_stream_data = recv_stream_data_cb,
+	.acked_stream_data_offset = acked_stream_data_offset_cb,
+	.stream_open = stream_open_cb,
+#ifndef NGTCP2_CALLBACKS_V5
+	.stream_close = stream_close_cb,
+#endif /* !NGTCP2_CALLBACKS_V5 */
+	.rand = rand_cb,
+#ifndef NGTCP2_CALLBACKS_V3
+	.get_new_connection_id = get_new_connection_id_cb,
+#endif /* !NGTCP2_CALLBACKS_V3 */
+	.remove_connection_id = remove_connection_id_cb,
+	.update_key = update_key_cb,
+	.delete_crypto_aead_ctx = delete_crypto_aead_ctx_cb,
+	.delete_crypto_cipher_ctx = delete_crypto_cipher_ctx_cb,
+#ifndef NGTCP2_CALLBACKS_V3
+	.get_path_challenge_data = get_path_challenge_data_cb,
+#endif /* !NGTCP2_CALLBACKS_V3 */
+	.version_negotiation = version_negotiation_cb,
+#ifdef NGTCP2_CALLBACKS_V3
+	.get_new_connection_id2 = get_new_connection_id2_cb,
+	.dcid_status2 = dcid_status2_cb,
+	.get_path_challenge_data2 = get_path_challenge_data2_cb,
+#endif /* NGTCP2_CALLBACKS_V3 */
+#ifdef NGTCP2_CALLBACKS_V4
+	.recv_stop_sending = recv_stop_sending_cb,
+#endif /* NGTCP2_CALLBACKS_V4 */
+#ifdef NGTCP2_CALLBACKS_V5
+	.stream_close2 = stream_close2_cb,
+#endif /* NGTCP2_CALLBACKS_V5 */
+};
+
+static const ngtcp2_crypto_ctx initial_aes128gcm_sha256_ctx = {
+	.aead = { .native_handle = (void *)(uintptr_t)
+			  ISC_CRYPTO_AEAD_ALGORITHM_AES128GCM,
+		  .max_overhead = isc_crypto_aes128gcm_tag_length },
+	.md = { .native_handle = (void *)(uintptr_t)ISC_MD_SHA256 },
+	.hp = { .native_handle = (void *)(uintptr_t)
+			ISC_CRYPTO_QUIC_HP_PROTECT_ALGORITHM_AES128 },
+	.max_encryption = aes_gcm_max_encryption,
+	.max_decryption_failure = aes_gcm_max_decryption_failure,
+};
+
+static const ngtcp2_crypto_ctx initial_aes256_sha384_ctx = {
+	.aead = { .native_handle = (void *)(uintptr_t)
+			  ISC_CRYPTO_AEAD_ALGORITHM_AES256GCM,
+		  .max_overhead = isc_crypto_aes256gcm_tag_length, },
+	.md = { .native_handle = (void *)(uintptr_t)ISC_MD_SHA384 },
+	.hp = { .native_handle = (void *)(uintptr_t)
+			ISC_CRYPTO_QUIC_HP_PROTECT_ALGORITHM_AES256 },
+	.max_encryption = aes_gcm_max_encryption,
+	.max_decryption_failure = aes_gcm_max_decryption_failure,
+};
+
+static const ngtcp2_crypto_ctx initial_chacha20poly1305_sha256_ctx = {
+	.aead = { .native_handle = (void *)(uintptr_t)
+			  ISC_CRYPTO_AEAD_ALGORITHM_CHACHA20POLY1305,
+		  .max_overhead = isc_crypto_chacha20poly1305_tag_length },
+	.md = { .native_handle = (void *)(uintptr_t)ISC_MD_SHA256 },
+	.hp = { .native_handle = (void *)(uintptr_t)
+			ISC_CRYPTO_QUIC_HP_PROTECT_ALGORITHM_CHACHA20 },
+	.max_encryption = chacha20poly1305_max_encryption,
+	.max_decryption_failure = chacha20poly1305_max_decryption_failure,
+};
+
+/**
+ * \brief
+ * The HKDF label used when deriving the QUIC client-side initial packet secret.
+ *
+ * The value is specified in RFC9001, Section 5.2.
+ */
+static const uint8_t client_label[] = {
+	'c', 'l', 'i', 'e', 'n', 't', ' ', 'i', 'n',
+};
+
+/**
+ * \brief
+ * The HKDF label used when deriving the QUIC server-side initial packet secret.
+ *
+ * The value is specified in RFC9001, Section 5.2.
+ */
+static const uint8_t server_label[] = {
+	's', 'e', 'r', 'v', 'e', 'r', ' ', 'i', 'n',
+};
+
+/**
+ * \brief
+ * The HKDF label used when deriving QUIC v1 packet protection AEAD keys.
+ *
+ * The value is specified in RFC9001, Section 5.1.
+ */
+static const uint8_t key_label_v1[] = {
+	'q', 'u', 'i', 'c', ' ', 'k', 'e', 'y',
+};
+
+/**
+ * \brief
+ * The HKDF label used when deriving the QUIC v1 new write secret in a key
+ * update.
+ *
+ * The value is specified in RFC9001, Section 6.1.
+ */
+static const uint8_t key_update_label_v1[] = {
+	'q', 'u', 'i', 'c', ' ', 'k', 'u',
+};
+
+/**
+ * \brief
+ * The HKDF label used when deriving the QUIC v1 packet protection AEAD nonce.
+ *
+ * The value is specified in RFC9001, Section 5.1.
+ */
+static const uint8_t iv_label_v1[] = {
+	'q', 'u', 'i', 'c', ' ', 'i', 'v',
+};
+
+/**
+ * \brief
+ * The HKDF label used when deriving the QUIC v1 header protection key.
+ *
+ * The value is specified in RFC9001, Section 5.4.
+ */
+static const uint8_t hp_label_v1[] = {
+	'q', 'u', 'i', 'c', ' ', 'h', 'p',
+};
+
+/**
+ * \brief
+ * The HKDF label used when deriving the QUIC v2 packet protection keys.
+ *
+ * The value is specified in RFC9369, Section 3.3.2.
+ */
+static const uint8_t key_label_v2[] = {
+	'q', 'u', 'i', 'c', 'v', '2', ' ', 'k', 'e', 'y',
+};
+
+/**
+ * \brief
+ * The HKDF label used when deriving the QUIC v2 new write secret in a key
+ * update.
+ *
+ * The value is specified in RFC9369, Section 3.3.2.
+ */
+static const uint8_t key_update_label_v2[] = {
+	'q', 'u', 'i', 'c', 'v', '2', ' ', 'k', 'u',
+};
+
+/**
+ * \brief
+ * The HKDF label used when deriving the QUIC v2 packet protection AEAD nonce.
+ *
+ * The value is specified in RFC9369, Section 3.3.2.
+ */
+static const uint8_t iv_label_v2[] = {
+	'q', 'u', 'i', 'c', 'v', '2', ' ', 'i', 'v',
+};
+
+/**
+ * \brief
+ * The HKDF label used when deriving the QUIC v1 header protection key.
+ *
+ * The value is specified in RFC9369, Section 3.3.2.
+ */
+static const uint8_t hp_label_v2[] = {
+	'q', 'u', 'i', 'c', 'v', '2', ' ', 'h', 'p',
+};
+
+/**
+ * \brief
+ * Salt value of the HKDF-Extract when the deriving QUIC v1 initial secrets.
+ *
+ * The value is specified in RFC9001 Section 5.2.
+ */
+static const uint8_t salt_v1[] = {
+	0x38, 0x76, 0x2C, 0xF7, 0xF5, 0x59, 0x34, 0xB3, 0x4D, 0x17,
+	0x9A, 0xE6, 0xA4, 0xC8, 0x0C, 0xAD, 0xCC, 0xBB, 0x7F, 0x0A,
+};
+
+/**
+ * \brief
+ * Salt value of the HKDF-Extract when deriving the QUIC v2 initial secrets.
+ *
+ * The value is specified in RFC9369 Section 3.3.1.
+ */
+static const uint8_t salt_v2[] = {
+	0x0D, 0xED, 0xE3, 0xDE, 0xF7, 0x00, 0xA6, 0xDB, 0x81, 0x93,
+	0x81, 0xBE, 0x6E, 0x26, 0x9D, 0xCB, 0xF9, 0xBD, 0x2E, 0xD9,
+};
+
 static void
 destroy(isc_quic_conn_t *conn) {
-	isc_mem_t *mctx = conn->mem.user_data;
+	isc_mem_t *mctx;
+	isc_tls_t *tls;
+
+	conn->magic = 0x00;
+	mctx = conn->mem.user_data;
+
+#if NGTCP2_VERSION_NUM >= 0x011700 /* 1.23.0 */
+	tls = ngtcp2_conn_get_tls_native_handle2(conn->inner);
+#else  /* NGTCP2_VERSION_NUM >= 0x011700 */
+	tls = ngtcp2_conn_get_tls_native_handle(conn->inner);
+#endif /* NGTCP2_VERSION_NUM >= 0x011700 */
+
+	ISC_LIST_FOREACH(conn->streams, stream, link) {
+		ISC_LIST_FOREACH(stream->ack_data, data, link) {
+			isc_mem_put(
+				mctx, data,
+				STRUCT_FLEX_SIZE(data, bytes, data->length));
+		}
+
+		isc_mem_put(mctx, stream, sizeof(*stream));
+	}
+
+	ISC_LIST_FOREACH(conn->outgoing_stream_data, data, link) {
+		isc_mem_put(mctx, data,
+			    STRUCT_FLEX_SIZE(data, bytes, data->length));
+	}
+
+#ifdef HAVE_OPENSSL_3
+	if (conn->local_transport_params != NULL) {
+		isc_mem_free(mctx, conn->local_transport_params);
+	}
+
+	ISC_LIST_FOREACH(conn->crypto_buffered_frames, frame, link) {
+		isc_mem_put(mctx, frame,
+			    STRUCT_FLEX_SIZE(frame, data, frame->len));
+	}
+
+	ISC_LIST_FOREACH(conn->crypto_awaiting_frames, frame, link) {
+		isc_mem_put(mctx, frame,
+			    STRUCT_FLEX_SIZE(frame, data, frame->len));
+	}
+#endif /* HAVE_OPENSSL_3 */
+
+	isc_tls_free(&tls);
+
+	ngtcp2_conn_del(conn->inner);

 	isc_quic_router_unref(conn->router);

@@ -36,6 +515,1381 @@ destroy(isc_quic_conn_t *conn) {
 	isc_mem_unref(mctx);
 }

+static isc_result_t
+derive_initial_secret(isc_region_t secret, isc_region_t self_secret,
+		      isc_region_t peer_secret, uint32_t version,
+		      bool is_server, const ngtcp2_cid *cid) {
+	isc_region_t client_secret, server_secret;
+	isc_constregion_t salt;
+	isc_constregion_t initial_secret = {
+		.base = secret.base,
+		.length = secret.length,
+	};
+
+	switch (version) {
+	case NGTCP2_PROTO_VER_V1:
+		salt = (isc_constregion_t){
+			.base = salt_v1,
+			.length = sizeof(salt_v1),
+		};
+		break;
+	case NGTCP2_PROTO_VER_V2:
+		salt = (isc_constregion_t){
+			.base = salt_v2,
+			.length = sizeof(salt_v2),
+		};
+		break;
+	default:
+		return ISC_R_NOTIMPLEMENTED;
+	}
+
+	if (is_server) {
+		client_secret = peer_secret;
+		server_secret = self_secret;
+	} else {
+		client_secret = self_secret;
+		server_secret = peer_secret;
+	}
+
+	RETERR(isc_crypto_hkdf_extract(
+		secret, ISC_MD_SHA256,
+		(isc_constregion_t){ cid->datalen, cid->data }, salt));
+
+	RETERR(isc_crypto_hkdf_expand_label(
+		client_secret, ISC_MD_SHA256, initial_secret,
+		(isc_constregion_t){ sizeof(client_label), client_label }));
+
+	RETERR(isc_crypto_hkdf_expand_label(
+		server_secret, ISC_MD_SHA256, initial_secret,
+		(isc_constregion_t){ sizeof(server_label), server_label }));
+
+	return ISC_R_SUCCESS;
+}
+
+static isc_result_t
+derive_packet_keys(const uint32_t version, isc_md_type_t md,
+		   isc_constregion_t secret, isc_region_t key, isc_region_t iv,
+		   isc_region_t hp) {
+	isc_constregion_t key_label, iv_label, hp_label;
+	isc_result_t result;
+
+	switch (version) {
+	case NGTCP2_PROTO_VER_V1:
+		key_label = (isc_constregion_t){
+			.base = key_label_v1,
+			.length = sizeof(key_label_v1),
+		};
+		iv_label = (isc_constregion_t){
+			.base = iv_label_v1,
+			.length = sizeof(iv_label_v1),
+		};
+		hp_label = (isc_constregion_t){
+			.base = hp_label_v1,
+			.length = sizeof(hp_label_v1),
+		};
+		break;
+	case NGTCP2_PROTO_VER_V2:
+		key_label = (isc_constregion_t){
+			.base = key_label_v2,
+			.length = sizeof(key_label_v2),
+		};
+		iv_label = (isc_constregion_t){
+			.base = iv_label_v2,
+			.length = sizeof(iv_label_v2),
+		};
+		hp_label = (isc_constregion_t){
+			.base = hp_label_v2,
+			.length = sizeof(hp_label_v2),
+		};
+		break;
+	default:
+		return ISC_R_NOTIMPLEMENTED;
+	}
+
+	CHECK(isc_crypto_hkdf_expand_label(key, md, secret, key_label));
+	CHECK(isc_crypto_hkdf_expand_label(iv, md, secret, iv_label));
+	if (hp.base != NULL) {
+		CHECK(isc_crypto_hkdf_expand_label(hp, md, secret, hp_label));
+	}
+
+	return ISC_R_SUCCESS;
+
+cleanup:
+	isc_safe_memwipe(key.base, key.length);
+	isc_safe_memwipe(iv.base, iv.length);
+	if (hp.base != NULL) {
+		isc_safe_memwipe(hp.base, hp.length);
+	}
+	return result;
+}
+
+static isc_result_t
+derive_traffic_update(isc_region_t next_secret, uint32_t version,
+		      isc_md_type_t md, isc_constregion_t secret) {
+	isc_constregion_t label;
+
+	switch (version) {
+	case NGTCP2_PROTO_VER_V1:
+		label = (isc_constregion_t){
+			.base = key_update_label_v1,
+			.length = sizeof(key_update_label_v1),
+		};
+		break;
+	case NGTCP2_PROTO_VER_V2:
+		label = (isc_constregion_t){
+			.base = key_update_label_v2,
+			.length = sizeof(key_update_label_v2),
+		};
+		break;
+	default:
+		return ISC_R_NOTIMPLEMENTED;
+	}
+
+	return isc_crypto_hkdf_expand_label(next_secret, md, secret, label);
+}
+
+/**
+ * Initial setup uses:
+ * \li AES-128 for the header protection
+ * \li AES-128-GCM for AEAD
+ */
+static isc_result_t
+setup_initial_key(ngtcp2_conn *ngconn, const ngtcp2_cid *dcid) {
+	uint8_t self_secret[QUIC_INITIAL_SECRET_LENGTH],
+		self_key[QUIC_AES128_KEY_LENGTH],
+		self_iv[ISC_CRYPTO_AEAD_MAX_NONCE_LENGTH],
+		self_hp[QUIC_AES128_KEY_LENGTH];
+	uint8_t peer_secret[QUIC_INITIAL_SECRET_LENGTH],
+		peer_key[QUIC_AES128_KEY_LENGTH],
+		peer_iv[ISC_CRYPTO_AEAD_MAX_NONCE_LENGTH],
+		peer_hp[QUIC_AES128_KEY_LENGTH];
+	ngtcp2_crypto_aead_ctx self_aead_ctx = { 0 }, peer_aead_ctx = { 0 };
+	ngtcp2_crypto_cipher_ctx self_hp_ctx = { 0 }, peer_hp_ctx = { 0 };
+	isc_crypto_quic_hp_protect_t *hp = NULL;
+	isc_crypto_aead_t *aead = NULL;
+	isc_result_t result;
+	uint32_t version;
+	bool is_server;
+
+	uint8_t initial_secret[QUIC_INITIAL_SECRET_LENGTH];
+
+#if NGTCP2_VERSION_NUM >= 0x011700 /* 1.23.0 */
+	version = ngtcp2_conn_get_client_chosen_version2(ngconn);
+	is_server = ngtcp2_conn_is_server2(ngconn);
+#else  /* NGTCP2_VERSION_NUM >= 0x011700 */
+	version = ngtcp2_conn_get_client_chosen_version(ngconn);
+	is_server = ngtcp2_conn_is_server(ngconn);
+#endif /* NGTCP2_VERSION_NUM >= 0x011700 */
+
+	ngtcp2_conn_set_initial_crypto_ctx(ngconn,
+					   &initial_aes128gcm_sha256_ctx);
+
+	CHECK(derive_initial_secret(
+		(isc_region_t){ sizeof(initial_secret), initial_secret },
+		(isc_region_t){ sizeof(self_secret), self_secret },
+		(isc_region_t){ sizeof(peer_secret), peer_secret }, version,
+		is_server, dcid));
+
+	CHECK(derive_packet_keys(
+		version, ISC_MD_SHA256,
+		(isc_constregion_t){ sizeof(self_secret), self_secret },
+		(isc_region_t){ sizeof(self_key), self_key },
+		(isc_region_t){ sizeof(self_iv), self_iv },
+		(isc_region_t){ sizeof(self_hp), self_hp }));
+
+	CHECK(derive_packet_keys(
+		version, ISC_MD_SHA256,
+		(isc_constregion_t){ sizeof(peer_secret), peer_secret },
+		(isc_region_t){ sizeof(peer_key), peer_key },
+		(isc_region_t){ sizeof(peer_iv), peer_iv },
+		(isc_region_t){ sizeof(peer_hp), peer_hp }));
+
+	CHECK(isc_crypto_aead_create(
+		ISC_CRYPTO_AEAD_ALGORITHM_AES128GCM,
+		(isc_constregion_t){ sizeof(self_key), self_key },
+		ISC_CRYPTO_AEAD_DIRECTION_SEAL, &aead));
+	self_aead_ctx.native_handle = MOVE_OWNERSHIP(aead);
+
+	CHECK(isc_crypto_aead_create(
+		ISC_CRYPTO_AEAD_ALGORITHM_AES128GCM,
+		(isc_constregion_t){ sizeof(peer_key), peer_key },
+		ISC_CRYPTO_AEAD_DIRECTION_OPEN, &aead));
+	peer_aead_ctx.native_handle = MOVE_OWNERSHIP(aead);
+
+	CHECK(isc_crypto_quic_hp_protect_create(
+		isc_g_mctx,
+		(isc_constregion_t){ isc_crypto_aes128gcm_key_length, self_hp },
+		ISC_CRYPTO_QUIC_HP_PROTECT_ALGORITHM_AES128, &hp));
+	self_hp_ctx.native_handle = MOVE_OWNERSHIP(hp);
+
+	CHECK(isc_crypto_quic_hp_protect_create(
+		isc_g_mctx,
+		(isc_constregion_t){ isc_crypto_aes128gcm_key_length, peer_hp },
+		ISC_CRYPTO_QUIC_HP_PROTECT_ALGORITHM_AES128, &hp));
+	peer_hp_ctx.native_handle = MOVE_OWNERSHIP(hp);
+
+	INSIST(ngtcp2_conn_install_initial_key(
+		       ngconn, &peer_aead_ctx, peer_iv, &peer_hp_ctx,
+		       &self_aead_ctx, self_iv, &self_hp_ctx,
+		       isc_crypto_aes128gcm_nonce_length) == 0);
+
+	return ISC_R_SUCCESS;
+
+cleanup:
+	if (hp != NULL) {
+		isc_crypto_quic_hp_protect_destroy(&hp);
+	}
+	if (self_hp_ctx.native_handle != NULL) {
+		hp = self_hp_ctx.native_handle;
+		isc_crypto_quic_hp_protect_destroy(&hp);
+	}
+	if (peer_hp_ctx.native_handle != NULL) {
+		hp = peer_hp_ctx.native_handle;
+		isc_crypto_quic_hp_protect_destroy(&hp);
+	}
+	if (aead != NULL) {
+		isc_crypto_aead_destroy(&aead);
+	}
+	if (self_aead_ctx.native_handle != NULL) {
+		aead = self_aead_ctx.native_handle;
+		isc_crypto_aead_destroy(&aead);
+	}
+	if (peer_aead_ctx.native_handle != NULL) {
+		aead = peer_aead_ctx.native_handle;
+		isc_crypto_aead_destroy(&aead);
+	}
+	return result;
+}
+
+static int
+client_initial_cb(ngtcp2_conn *ngconn, void *user_data) {
+	const ngtcp2_cid *dcid;
+	isc_quic_conn_t *conn;
+	isc_result_t result;
+	isc_tls_t *tls;
+
+#if NGTCP2_VERSION_NUM >= 0x011700 /* 1.23.0 */
+	tls = ngtcp2_conn_get_tls_native_handle2(ngconn);
+	dcid = ngtcp2_conn_get_dcid2(ngconn);
+#else  /* NGTCP2_VERSION_NUM >= 0x011700 */
+	tls = ngtcp2_conn_get_tls_native_handle(ngconn);
+	dcid = ngtcp2_conn_get_dcid(ngconn);
+#endif /* NGTCP2_VERSION_NUM >= 0x011700 */
+
+	conn = user_data;
+
+	result = setup_initial_key(ngconn, dcid);
+	if (result != ISC_R_SUCCESS) {
+		return NGTCP2_ERR_CALLBACK_FAILURE;
+	}
+
+	if (isc__quic_set_local_transport_params(conn, tls) != ISC_R_SUCCESS) {
+		return NGTCP2_ERR_CALLBACK_FAILURE;
+	}
+
+	result = isc__quic_do_tls(conn, tls, NGTCP2_ENCRYPTION_LEVEL_INITIAL,
+				  (isc_constregion_t){ 0, NULL });
+	if (result != ISC_R_SUCCESS) {
+		return NGTCP2_ERR_CALLBACK_FAILURE;
+	}
+
+	return 0;
+}
+
+static int
+recv_client_initial_cb(ngtcp2_conn *ngconn, const ngtcp2_cid *dcid,
+		       void *user_data ISC_ATTR_UNUSED) {
+	if (setup_initial_key(ngconn, dcid) != ISC_R_SUCCESS) {
+		return NGTCP2_ERR_CALLBACK_FAILURE;
+	}
+
+	return 0;
+}
+
+static int
+recv_crypto_data_cb(ngtcp2_conn *ngconn, ngtcp2_encryption_level nglevel,
+		    uint64_t offset ISC_ATTR_UNUSED, const uint8_t *data,
+		    size_t len, void *user_data) {
+	isc_quic_conn_t *conn = user_data;
+	isc_tls_t *tls;
+	int r;
+
+#if NGTCP2_VERSION_NUM >= 0x011700 /* 1.23.0 */
+	tls = ngtcp2_conn_get_tls_native_handle2(ngconn);
+#else  /* NGTCP2_VERSION_NUM >= 0x011700 */
+	tls = ngtcp2_conn_get_tls_native_handle(ngconn);
+#endif /* NGTCP2_VERSION_NUM >= 0x011700 */
+
+	if (isc__quic_do_tls(conn, tls, nglevel,
+			     (isc_constregion_t){ len, data }) != ISC_R_SUCCESS)
+	{
+#if NGTCP2_VERSION_NUM >= 0x011700 /* 1.23.0 */
+		r = ngtcp2_conn_get_tls_error2(ngconn);
+#else  /* NGTCP2_VERSION_NUM >= 0x011700 */
+		r = ngtcp2_conn_get_tls_error(ngconn);
+#endif /* NGTCP2_VERSION_NUM >= 0x011700 */
+		if (r == 0) {
+			return NGTCP2_ERR_CRYPTO;
+		}
+		return r;
+	}
+
+	return 0;
+}
+
+static int
+handshake_completed_cb(ngtcp2_conn *ngconn ISC_ATTR_UNUSED, void *user_data) {
+	isc_quic_conn_t *conn = user_data;
+
+	CHECK_STATE(conn, QUIC_CONN_STATE_HANDSHAKE);
+	conn->state = QUIC_CONN_STATE_CONNECTED;
+
+	if (conn->cb != NULL && conn->cb->handshake_completed != NULL) {
+		conn->cb->handshake_completed(conn->cbarg);
+	}
+
+	return 0;
+}
+
+static int
+encrypt_cb(uint8_t *dest, const ngtcp2_crypto_aead *ngaead,
+	   const ngtcp2_crypto_aead_ctx *ngctx, const uint8_t *plaintext,
+	   size_t plaintextlen, const uint8_t *nonceptr, size_t noncelen,
+	   const uint8_t *aad, size_t aadlen) {
+	size_t out_sealed_len;
+
+	if (isc_crypto_aead_seal(
+		    ngctx->native_handle,
+		    (isc_constregion_t){ noncelen, nonceptr },
+		    (isc_constregion_t){ plaintextlen, plaintext },
+		    (isc_region_t){ plaintextlen + ngaead->max_overhead, dest },
+		    &out_sealed_len,
+		    (isc_constregion_t){ aadlen, aad }) != ISC_R_SUCCESS)
+	{
+		return NGTCP2_ERR_CALLBACK_FAILURE;
+	}
+
+	return 0;
+}
+
+static int
+decrypt_cb(uint8_t *out_ptr, const ngtcp2_crypto_aead *ngaead,
+	   const ngtcp2_crypto_aead_ctx *ngctx, const uint8_t *ciphertext_ptr,
+	   size_t ciphertext_len, const uint8_t *nonce_ptr, size_t nonce_len,
+	   const uint8_t *aad_ptr, size_t aad_len) {
+	size_t out_opened_len;
+
+	if (isc_crypto_aead_open(
+		    ngctx->native_handle,
+		    (isc_constregion_t){ nonce_len, nonce_ptr },
+		    (isc_constregion_t){ ciphertext_len, ciphertext_ptr },
+		    (isc_region_t){ ciphertext_len - ngaead->max_overhead,
+				    out_ptr },
+		    &out_opened_len,
+		    (isc_constregion_t){ aad_len, aad_ptr }) != ISC_R_SUCCESS)
+	{
+		return NGTCP2_ERR_DECRYPT;
+	}
+
+	return 0;
+}
+
+static int
+hp_mask_cb(uint8_t *dest, const ngtcp2_crypto_cipher *nghp ISC_ATTR_UNUSED,
+	   const ngtcp2_crypto_cipher_ctx *ngctx, const uint8_t *sample) {
+	isc_crypto_quic_hp_protect_t *hp = ngctx->native_handle;
+
+	if (isc_crypto_quic_hp_protect_mask(hp, dest, sample) != ISC_R_SUCCESS)
+	{
+		return NGTCP2_ERR_CALLBACK_FAILURE;
+	}
+
+	return 0;
+}
+
+static int
+recv_stream_data_cb(ngtcp2_conn *ngconn ISC_ATTR_UNUSED, uint32_t flags,
+		    int64_t stream_id, uint64_t offset ISC_ATTR_UNUSED,
+		    const uint8_t *data, size_t datalen, void *user_data,
+		    void *stream_user_data ISC_ATTR_UNUSED) {
+	isc_quic_conn_t *conn = user_data;
+	const isc_quic_stream_data_info_t info = {
+		.final = (flags & NGTCP2_STREAM_DATA_FLAG_FIN) != 0x00,
+		.zerortt = (flags & NGTCP2_STREAM_DATA_FLAG_0RTT) != 0x00,
+		.stream_id = stream_id,
+	};
+
+	if (conn->cb != NULL && conn->cb->data_read != NULL) {
+		if (conn->cb->data_read(conn, conn->cbarg, info,
+					(isc_constregion_t){ datalen, data }) !=
+		    ISC_R_SUCCESS)
+		{
+			return NGTCP2_ERR_CALLBACK_FAILURE;
+		}
+	}
+
+	return 0;
+}
+
+static int
+acked_stream_data_offset_cb(ngtcp2_conn *ngconn ISC_ATTR_UNUSED,
+			    int64_t stream_id, uint64_t offset,
+			    uint64_t datalen, void *user_data,
+			    void *stream_user_data) {
+	isc__quic_stream_t *stream = stream_user_data;
+	isc_quic_conn_t *conn = user_data;
+	uint64_t last_acked_offset = stream->last_acked_offset;
+
+	INSIST(stream->id == stream_id);
+
+	ISC_LIST_FOREACH(stream->ack_data, data, link) {
+		if (last_acked_offset + data->length > offset + datalen) {
+			break;
+		}
+		last_acked_offset += data->length;
+		ISC_LIST_UNLINK(stream->ack_data, data, link);
+		isc_mem_put(conn->mem.user_data, data,
+			    STRUCT_FLEX_SIZE(data, bytes, data->length));
+	}
+
+	stream->last_acked_offset = offset;
+
+	return 0;
+}
+
+static int
+stream_open_cb(ngtcp2_conn *ngconn, int64_t stream_id, void *user_data) {
+	isc_quic_conn_t *conn = user_data;
+	isc__quic_stream_t *stream;
+	isc_result_t result;
+
+	if (conn->cb != NULL && conn->cb->stream_opened != NULL) {
+		result = conn->cb->stream_opened(conn, conn->cbarg, stream_id);
+		if (result != ISC_R_SUCCESS) {
+			return NGTCP2_ERR_CALLBACK_FAILURE;
+		}
+	}
+
+	stream = isc_mem_get(conn->mem.user_data, sizeof(*stream));
+	*stream = (isc__quic_stream_t){
+		.id = stream_id,
+		.ack_data = ISC_LIST_INITIALIZER,
+		.link = ISC_LINK_INITIALIZER,
+	};
+
+	if (ngtcp2_conn_set_stream_user_data(ngconn, stream_id, stream) != 0) {
+		isc_mem_put(conn->mem.user_data, stream, sizeof(*stream));
+		return NGTCP2_ERR_CALLBACK_FAILURE;
+	}
+
+	ISC_LIST_APPEND(conn->streams, stream, link);
+
+	return 0;
+}
+
+#ifndef NGTCP2_CALLBACKS_V5
+static int
+stream_close_cb(ngtcp2_conn *ngconn ISC_ATTR_UNUSED, uint32_t flags,
+		int64_t stream_id, uint64_t app_error_code, void *user_data,
+		void *stream_user_data) {
+	isc_quic_application_error_kind_t kind;
+	isc__quic_stream_t *stream = stream_user_data;
+	isc_quic_conn_t *conn = user_data;
+	isc_result_t result;
+
+	ISC_LIST_FOREACH(stream->ack_data, data, link) {
+		ISC_LIST_DEQUEUE(stream->ack_data, data, link);
+		isc_mem_put(conn->mem.user_data, data,
+			    STRUCT_FLEX_SIZE(data, bytes, data->length));
+	}
+
+	if (conn->cb != NULL && conn->cb->stream_closed != NULL) {
+		if (flags & NGTCP2_STREAM_CLOSE_FLAG_APP_ERROR_CODE_SET) {
+			kind = ISC_QUIC_APPLICATION_ERROR_UNKNOWN;
+		} else {
+			kind = ISC_QUIC_APPLICATION_ERROR_NONE;
+		}
+
+		result = conn->cb->stream_closed(
+			conn, conn->cbarg, stream_id, stream->data, kind,
+			app_error_code, app_error_code);
+		if (result != ISC_R_SUCCESS) {
+			return NGTCP2_ERR_CALLBACK_FAILURE;
+		}
+	}
+
+	return 0;
+}
+#endif /* !NGTCP2_CALLBACKS_V5 */
+
+static int
+recv_retry_cb(ngtcp2_conn *ngconn, const ngtcp2_pkt_hd *header,
+	      void *user_data ISC_ATTR_UNUSED) {
+	if (setup_initial_key(ngconn, &header->dcid) != ISC_R_SUCCESS) {
+		return NGTCP2_ERR_CALLBACK_FAILURE;
+	}
+
+	return 0;
+}
+
+static void
+rand_cb(uint8_t *dest, size_t destlen,
+	const ngtcp2_rand_ctx *rand_ctx ISC_ATTR_UNUSED) {
+	isc_random_buf(dest, destlen);
+}
+
+#ifndef NGTCP2_CALLBACKS_V3
+static int
+get_new_connection_id_cb(ngtcp2_conn *ngconn ISC_ATTR_UNUSED, ngtcp2_cid *ngcid,
+			 uint8_t *token, size_t cidlen, void *user_data) {
+	isc_quic_conn_t *conn = user_data;
+	uint8_t buffer[ISC_QUIC_CID_MAX_LENGTH];
+	isc_constregion_t cid = { cidlen, buffer };
+	isc_result_t result;
+
+	for (;;) {
+		isc_random_buf(buffer, sizeof(buffer));
+		result = isc_quic_router_add_cid(conn->router, cid, isc_tid(),
+						 conn);
+		if (result == ISC_R_SUCCESS) {
+			isc_quic_router_add_stateless_reset(conn->router, token,
+							    isc_tid(), conn);
+			ngcid->datalen = cidlen;
+			memmove(ngcid->data, buffer, cidlen);
+			return 0;
+		}
+	}
+
+	UNREACHABLE();
+}
+#endif /* NGTCP2_CALLBACKS_V3 */
+
+static int
+remove_connection_id_cb(ngtcp2_conn *ngconn ISC_ATTR_UNUSED,
+			const ngtcp2_cid *ngcid, void *user_data) {
+	isc_constregion_t cid = { ngcid->datalen, ngcid->data };
+	isc_quic_conn_t *conn = user_data;
+
+	if (isc_quic_router_del_cid(conn->router, cid) != ISC_R_SUCCESS) {
+		return NGTCP2_ERR_CALLBACK_FAILURE;
+	}
+
+	return 0;
+}
+
+static int
+update_key_cb(ngtcp2_conn *ngconn, uint8_t *rx_secret, uint8_t *tx_secret,
+	      ngtcp2_crypto_aead_ctx *rx_aead_ctx, uint8_t *rx_iv,
+	      ngtcp2_crypto_aead_ctx *tx_aead_ctx, uint8_t *tx_iv,
+	      const uint8_t *current_rx_secret,
+	      const uint8_t *current_tx_secret, size_t len,
+	      void *user_data ISC_ATTR_UNUSED) {
+	uint8_t rx_key[ISC_CRYPTO_AEAD_MAX_KEY_LENGTH],
+		tx_key[ISC_CRYPTO_AEAD_MAX_KEY_LENGTH];
+	const ngtcp2_crypto_ctx *ctx;
+	isc_crypto_aead_algorithm_t aead_algorithm;
+	isc_crypto_aead_t *aead = NULL;
+	isc_result_t result;
+	isc_md_type_t md;
+	uint32_t version;
+	size_t nonce_len, key_len;
+
+#if NGTCP2_VERSION_NUM >= 0x011700 /* 1.23.0 */
+	ctx = ngtcp2_conn_get_crypto_ctx2(ngconn);
+	version = ngtcp2_conn_get_client_chosen_version2(ngconn);
+#else  /* NGTCP2_VERSION_NUM >= 0x011700 */
+	ctx = ngtcp2_conn_get_crypto_ctx(ngconn);
+	version = ngtcp2_conn_get_client_chosen_version(ngconn);
+#endif /* NGTCP2_VERSION_NUM >= 0x011700 */
+
+	md = (uintptr_t)(void *)ctx->md.native_handle;
+	INSIST(md < ISC_MD_MAX);
+
+	aead_algorithm = (uintptr_t)(void *)ctx->aead.native_handle;
+	switch (aead_algorithm) {
+	case ISC_CRYPTO_AEAD_ALGORITHM_AES128GCM:
+		nonce_len = isc_crypto_aes128gcm_nonce_length;
+		key_len = isc_crypto_aes128gcm_key_length;
+		break;
+	case ISC_CRYPTO_AEAD_ALGORITHM_AES256GCM:
+		nonce_len = isc_crypto_aes256gcm_nonce_length;
+		key_len = isc_crypto_aes256gcm_key_length;
+		break;
+	case ISC_CRYPTO_AEAD_ALGORITHM_CHACHA20POLY1305:
+		nonce_len = isc_crypto_chacha20poly1305_nonce_length;
+		key_len = isc_crypto_chacha20poly1305_key_length;
+		break;
+	default:
+		UNREACHABLE();
+	}
+
+	CHECK(derive_traffic_update(
+		(isc_region_t){ len, rx_secret }, version, md,
+		(isc_constregion_t){ len, current_rx_secret }));
+
+	CHECK(derive_packet_keys(
+		version, md, (isc_constregion_t){ len, rx_secret },
+		(isc_region_t){ key_len, rx_key },
+		(isc_region_t){ nonce_len, rx_iv }, (isc_region_t){ 0, NULL }));
+
+	CHECK(isc_crypto_aead_create(aead_algorithm,
+				     (isc_constregion_t){ key_len, rx_key },
+				     ISC_CRYPTO_AEAD_DIRECTION_OPEN, &aead));
+	rx_aead_ctx->native_handle = MOVE_OWNERSHIP(aead);
+
+	CHECK(derive_traffic_update(
+		(isc_region_t){ len, tx_secret }, version, md,
+		(isc_constregion_t){ len, current_tx_secret }));
+
+	CHECK(derive_packet_keys(
+		version, md, (isc_constregion_t){ len, tx_secret },
+		(isc_region_t){ key_len, tx_key },
+		(isc_region_t){ nonce_len, tx_iv }, (isc_region_t){ 0, NULL }));
+
+	CHECK(isc_crypto_aead_create(aead_algorithm,
+				     (isc_constregion_t){ key_len, tx_key },
+				     ISC_CRYPTO_AEAD_DIRECTION_SEAL, &aead));
+	tx_aead_ctx->native_handle = MOVE_OWNERSHIP(aead);
+
+	return 0;
+
+cleanup:
+	if (aead != NULL) {
+		isc_crypto_aead_destroy(&aead);
+	}
+
+	if (rx_aead_ctx->native_handle != NULL) {
+		aead = MOVE_OWNERSHIP(rx_aead_ctx->native_handle);
+		isc_crypto_aead_destroy(&aead);
+	}
+
+	return NGTCP2_ERR_CALLBACK_FAILURE;
+}
+
+static void
+delete_crypto_aead_ctx_cb(ngtcp2_conn *ngconn ISC_ATTR_UNUSED,
+			  ngtcp2_crypto_aead_ctx *ngctx,
+			  void *user_data ISC_ATTR_UNUSED) {
+	isc_crypto_aead_t *aead = MOVE_OWNERSHIP(ngctx->native_handle);
+	isc_crypto_aead_destroy(&aead);
+}
+
+static void
+delete_crypto_cipher_ctx_cb(ngtcp2_conn *ngconn ISC_ATTR_UNUSED,
+			    ngtcp2_crypto_cipher_ctx *ngctx,
+			    void *user_data ISC_ATTR_UNUSED) {
+	isc_crypto_quic_hp_protect_t *hp = MOVE_OWNERSHIP(ngctx->native_handle);
+	isc_crypto_quic_hp_protect_destroy(&hp);
+}
+
+#ifndef NGTCP2_CALLBACKS_V3
+static int
+get_path_challenge_data_cb(ngtcp2_conn *ngconn ISC_ATTR_UNUSED, uint8_t *data,
+			   void *user_data ISC_ATTR_UNUSED) {
+	isc_random_buf(data, NGTCP2_PATH_CHALLENGE_DATALEN);
+	return 0;
+}
+#endif /* NGTCP2_CALLBACKS_V3 */
+
+static int
+version_negotiation_cb(ngtcp2_conn *ngconn, uint32_t ngversion,
+		       const ngtcp2_cid *client_dcid,
+		       void *user_data ISC_ATTR_UNUSED) {
+	uint8_t self_secret[ISC_CRYPTO_AEAD_MAX_KEY_LENGTH],
+		self_key[QUIC_AES128_KEY_LENGTH],
+		self_iv[ISC_CRYPTO_AEAD_MAX_NONCE_LENGTH],
+		self_hp[QUIC_AES128_KEY_LENGTH];
+	uint8_t peer_secret[ISC_CRYPTO_AEAD_MAX_KEY_LENGTH],
+		peer_key[QUIC_AES128_KEY_LENGTH],
+		peer_iv[ISC_CRYPTO_AEAD_MAX_NONCE_LENGTH],
+		peer_hp[QUIC_AES128_KEY_LENGTH];
+	uint8_t initial_secret[QUIC_INITIAL_SECRET_LENGTH];
+
+	ngtcp2_crypto_aead_ctx self_aead_ctx = { 0 }, peer_aead_ctx = { 0 };
+	ngtcp2_crypto_cipher_ctx self_hp_ctx = { 0 }, peer_hp_ctx = { 0 };
+	isc_crypto_quic_hp_protect_t *hp = NULL;
+	isc_crypto_aead_t *aead = NULL;
+	isc_result_t result;
+	bool is_server;
+	int r;
+
+#if NGTCP2_VERSION_NUM >= 0x011700 /* 1.23.0 */
+	is_server = ngtcp2_conn_is_server2(ngconn);
+#else  /* NGTCP2_VERSION_NUM >= 0x011700 */
+	is_server = ngtcp2_conn_is_server(ngconn);
+#endif /* NGTCP2_VERSION_NUM >= 0x011700 */
+
+	CHECK(derive_initial_secret(
+		(isc_region_t){ sizeof(initial_secret), initial_secret },
+		(isc_region_t){ sizeof(self_secret), self_secret },
+		(isc_region_t){ sizeof(peer_secret), peer_secret }, ngversion,
+		is_server, client_dcid));
+
+	CHECK(derive_packet_keys(
+		ngversion, ISC_MD_SHA256,
+		(isc_constregion_t){ sizeof(self_secret), self_secret },
+		(isc_region_t){ sizeof(self_key), self_key },
+		(isc_region_t){ sizeof(self_iv), self_iv },
+		(isc_region_t){ sizeof(self_hp), self_hp }));
+
+	CHECK(isc_crypto_aead_create(
+		ISC_CRYPTO_AEAD_ALGORITHM_AES128GCM,
+		(isc_constregion_t){ sizeof(self_key), self_key },
+		ISC_CRYPTO_AEAD_DIRECTION_SEAL, &aead));
+	self_aead_ctx.native_handle = MOVE_OWNERSHIP(aead);
+
+	CHECK(isc_crypto_quic_hp_protect_create(
+		isc_g_mctx, (isc_constregion_t){ sizeof(self_hp), self_hp },
+		ISC_CRYPTO_QUIC_HP_PROTECT_ALGORITHM_AES128, &hp));
+	self_hp_ctx.native_handle = MOVE_OWNERSHIP(hp);
+
+	CHECK(derive_packet_keys(
+		ngversion, ISC_MD_SHA256,
+		(isc_constregion_t){ sizeof(peer_secret), peer_secret },
+		(isc_region_t){ sizeof(peer_key), peer_key },
+		(isc_region_t){ sizeof(peer_iv), peer_iv },
+		(isc_region_t){ sizeof(peer_hp), peer_hp }));
+
+	CHECK(isc_crypto_aead_create(
+		ISC_CRYPTO_AEAD_ALGORITHM_AES128GCM,
+		(isc_constregion_t){ sizeof(peer_key), peer_key },
+		ISC_CRYPTO_AEAD_DIRECTION_SEAL, &aead));
+	peer_aead_ctx.native_handle = MOVE_OWNERSHIP(aead);
+
+	CHECK(isc_crypto_quic_hp_protect_create(
+		isc_g_mctx, (isc_constregion_t){ sizeof(peer_hp), peer_hp },
+		ISC_CRYPTO_QUIC_HP_PROTECT_ALGORITHM_AES128, &hp));
+	peer_hp_ctx.native_handle = MOVE_OWNERSHIP(hp);
+
+	r = ngtcp2_conn_install_vneg_initial_key(
+		ngconn, ngversion, &peer_aead_ctx, peer_iv, &peer_hp_ctx,
+		&self_aead_ctx, self_iv, &self_hp_ctx,
+		isc_crypto_aes128gcm_nonce_length);
+	if (r != 0) {
+		goto cleanup;
+	}
+
+	return 0;
+
+cleanup:
+	if (hp != NULL) {
+		isc_crypto_quic_hp_protect_destroy(&hp);
+	}
+	if (self_hp_ctx.native_handle != NULL) {
+		hp = self_hp_ctx.native_handle;
+		isc_crypto_quic_hp_protect_destroy(&hp);
+	}
+	if (peer_hp_ctx.native_handle != NULL) {
+		hp = peer_hp_ctx.native_handle;
+		isc_crypto_quic_hp_protect_destroy(&hp);
+	}
+	if (aead != NULL) {
+		isc_crypto_aead_destroy(&aead);
+	}
+	if (self_aead_ctx.native_handle != NULL) {
+		aead = self_aead_ctx.native_handle;
+		isc_crypto_aead_destroy(&aead);
+	}
+	if (peer_aead_ctx.native_handle != NULL) {
+		aead = peer_aead_ctx.native_handle;
+		isc_crypto_aead_destroy(&aead);
+	}
+
+	return NGTCP2_ERR_CALLBACK_FAILURE;
+}
+
+#ifdef NGTCP2_CALLBACKS_V3
+static int
+get_new_connection_id2_cb(ngtcp2_conn *ngconn ISC_ATTR_UNUSED,
+			  ngtcp2_cid *ngcid,
+			  ngtcp2_stateless_reset_token *token, size_t cidlen,
+			  void *user_data) {
+	isc_quic_conn_t *conn = user_data;
+	uint8_t buffer[ISC_QUIC_CID_MAX_LENGTH];
+	isc_constregion_t cid = { cidlen, buffer };
+	isc_result_t result;
+
+	for (;;) {
+		isc_random_buf(buffer, sizeof(buffer));
+		result = isc_quic_router_add_cid(conn->router, cid, isc_tid(),
+						 conn);
+		if (result == ISC_R_SUCCESS) {
+			isc_quic_router_add_stateless_reset(
+				conn->router, token->data, isc_tid(), conn);
+			ngcid->datalen = cidlen;
+			memmove(ngcid->data, buffer, cidlen);
+			return 0;
+		}
+	}
+
+	UNREACHABLE();
+}
+
+static int
+dcid_status2_cb(ngtcp2_conn *ngconn ISC_ATTR_UNUSED,
+		ngtcp2_connection_id_status_type type,
+		uint64_t seq ISC_ATTR_UNUSED, const ngtcp2_cid *ngcid,
+		const ngtcp2_stateless_reset_token *ngtoken, void *user_data) {
+	isc_constregion_t cid = { ngcid->datalen, ngcid->data };
+	isc_quic_conn_t *conn = user_data;
+	isc_result_t result;
+	isc_tid_t tid = isc_tid();
+
+	switch (type) {
+	case NGTCP2_CONNECTION_ID_STATUS_TYPE_ACTIVATE:
+		result = isc_quic_router_add_cid(conn->router, cid, tid, conn);
+		if (result == ISC_R_SUCCESS && ngtoken != NULL) {
+			result = isc_quic_router_add_stateless_reset(
+				conn->router, ngtoken->data, tid, conn);
+			if (result != ISC_R_SUCCESS) {
+				(void)isc_quic_router_del_cid(conn->router,
+							      cid);
+			}
+		}
+		break;
+	case NGTCP2_CONNECTION_ID_STATUS_TYPE_DEACTIVATE:
+		result = isc_quic_router_del_cid(conn->router, cid);
+		if (result == ISC_R_SUCCESS && ngtoken != NULL) {
+			result = isc_quic_router_del_stateless_reset(
+				conn->router, ngtoken->data);
+			/* TODO(aydin) do we need to re-add the CID? */
+		}
+		break;
+	default:
+		/*
+		 * Other values might be added in the future so it is best to
+		 * not crash.
+		 *
+		 * https://nghttp2.org/ngtcp2/types.html#c.ngtcp2_connection_id_status2
+		 */
+		return NGTCP2_ERR_CALLBACK_FAILURE;
+	}
+
+	if (result != ISC_R_SUCCESS) {
+		return NGTCP2_ERR_CALLBACK_FAILURE;
+	}
+
+	return 0;
+}
+
+static int
+get_path_challenge_data2_cb(ngtcp2_conn *ngconn ISC_ATTR_UNUSED,
+			    ngtcp2_path_challenge_data *data,
+			    void *user_data ISC_ATTR_UNUSED) {
+	isc_random_buf(data->data, NGTCP2_PATH_CHALLENGE_DATALEN);
+	return 0;
+}
+
+#endif /* NGTCP2_CALLBACKS_V3 */
+
+#ifdef NGTCP2_CALLBACKS_V4
+static int
+recv_stop_sending_cb(ngtcp2_conn *ngconn ISC_ATTR_UNUSED,
+		     int64_t stream_id ISC_ATTR_UNUSED,
+		     uint64_t app_error_code ISC_ATTR_UNUSED, void *user_data,
+		     void *stream_user_data) {
+	isc__quic_stream_t *stream = stream_user_data;
+	isc_quic_conn_t *conn = user_data;
+	isc_mem_t *mctx = conn->mem.user_data;
+
+	ISC_LIST_UNLINK(conn->streams, stream, link);
+	ISC_LIST_FOREACH(stream->ack_data, data, link) {
+		ISC_LIST_DEQUEUE(stream->ack_data, data, link);
+		isc_mem_put(mctx, data,
+			    STRUCT_FLEX_SIZE(data, bytes, data->length));
+	}
+
+	isc_mem_put(mctx, stream, sizeof(*stream));
+
+	return 0;
+}
+#endif /* NGTCP2_CALLBACKS_V4 */
+
+#ifdef NGTCP2_CALLBACKS_V5
+static int
+stream_close2_cb(ngtcp2_conn *ngconn ISC_ATTR_UNUSED, uint32_t flags,
+		 int64_t stream_id, uint64_t rx_app_error_code,
+		 uint64_t tx_app_error_code, void *user_data,
+		 void *stream_user_data) {
+	isc_quic_application_error_kind_t kind;
+	isc__quic_stream_t *stream = stream_user_data;
+	isc_quic_conn_t *conn = user_data;
+	isc_result_t result;
+	bool rx, tx;
+
+	ISC_LIST_FOREACH(stream->ack_data, data, link) {
+		ISC_LIST_DEQUEUE(stream->ack_data, data, link);
+		isc_mem_put(conn->mem.user_data, data,
+			    STRUCT_FLEX_SIZE(data, bytes, data->length));
+	}
+
+	if (conn->cb != NULL && conn->cb->stream_closed != NULL) {
+		rx = flags & NGTCP2_STREAM_CLOSE2_FLAG_RX_APP_ERROR_CODE_SET;
+		tx = flags & NGTCP2_STREAM_CLOSE2_FLAG_TX_APP_ERROR_CODE_SET;
+
+		if (rx && tx) {
+			kind = ISC_QUIC_APPLICATION_ERROR_RX_AND_TX;
+		} else if (rx) {
+			kind = ISC_QUIC_APPLICATION_ERROR_RX_ONLY;
+		} else if (tx) {
+			kind = ISC_QUIC_APPLICATION_ERROR_TX_ONLY;
+		} else {
+			kind = ISC_QUIC_APPLICATION_ERROR_NONE;
+		}
+
+		result = conn->cb->stream_closed(conn, conn->cbarg, stream_id,
+						 kind, rx_app_error_code,
+						 tx_app_error_code);
+		if (result != ISC_R_SUCCESS) {
+			return NGTCP2_ERR_CALLBACK_FAILURE;
+		}
+	}
+
+	return 0;
+}
+#endif /* NGTCP2_CALLBACKS_V5 */
+
+isc_result_t
+isc__quic_setup_read_key(isc_quic_conn_t *conn, bool is_server,
+			 ngtcp2_encryption_level nglevel,
+			 isc_constregion_t secret) {
+	isc_crypto_quic_hp_protect_algorithm_t hp_algorithm;
+	isc_crypto_quic_hp_protect_t *hp = NULL;
+	isc_crypto_aead_algorithm_t aead_algorithm;
+	ngtcp2_crypto_cipher_ctx ng_hp_ctx = { 0 };
+	const ngtcp2_crypto_ctx *ngctx = NULL;
+	ngtcp2_crypto_aead_ctx ng_aead_ctx = { 0 };
+	isc_crypto_aead_t *aead = NULL;
+	const SSL_CIPHER *cipher;
+	isc_md_type_t md;
+	isc_result_t result;
+	isc_tls_t *tls;
+	uint32_t version;
+	size_t key_len, nonce_len;
+	uint8_t key_buffer[ISC_CRYPTO_AEAD_MAX_KEY_LENGTH],
+		nonce_buffer[ISC_CRYPTO_AEAD_MAX_NONCE_LENGTH],
+		hp_buffer[ISC_CRYPTO_AEAD_MAX_KEY_LENGTH];
+
+#if NGTCP2_VERSION_NUM >= 0x011700 /* 1.23.0 */
+	tls = ngtcp2_conn_get_tls_native_handle2(conn->inner);
+#else  /* NGTCP2_VERSION_NUM >= 0x011700 */
+	tls = ngtcp2_conn_get_tls_native_handle(conn->inner);
+#endif /* NGTCP2_VERSION_NUM >= 0x011700 */
+
+	switch (nglevel) {
+	case NGTCP2_ENCRYPTION_LEVEL_0RTT:
+		if (!is_server) {
+			return ISC_R_SUCCESS;
+		}
+
+		cipher = SSL_get_current_cipher(tls);
+		if (cipher == NULL) {
+			return ISC_R_FAILURE;
+		}
+
+		switch (SSL_CIPHER_get_id(cipher)) {
+		case TLS1_3_CK_AES_128_GCM_SHA256:
+			ngctx = &initial_aes128gcm_sha256_ctx;
+			break;
+		case TLS1_3_CK_AES_256_GCM_SHA384:
+			ngctx = &initial_aes256_sha384_ctx;
+			break;
+		case TLS1_3_CK_CHACHA20_POLY1305_SHA256:
+			ngctx = &initial_chacha20poly1305_sha256_ctx;
+			break;
+		default:
+			UNREACHABLE();
+		}
+
+		ngtcp2_conn_set_0rtt_crypto_ctx(conn->inner, ngctx);
+#if NGTCP2_VERSION_NUM >= 0x011700 /* 1.23.0 */
+		ngctx = ngtcp2_conn_get_0rtt_crypto_ctx2(conn->inner);
+		version = ngtcp2_conn_get_client_chosen_version2(conn->inner);
+#else  /* NGTCP2_VERSION_NUM >= 0x011700 */
+		ngctx = ngtcp2_conn_get_0rtt_crypto_ctx(conn->inner);
+		version = ngtcp2_conn_get_client_chosen_version(conn->inner);
+#endif /* NGTCP2_VERSION_NUM >= 0x011700 */
+		break;
+	case NGTCP2_ENCRYPTION_LEVEL_HANDSHAKE:
+#if NGTCP2_VERSION_NUM >= 0x011700 /* 1.23.0 */
+		version = ngtcp2_conn_get_negotiated_version2(conn->inner);
+#else  /* NGTCP2_VERSION_NUM >= 0x011700 */
+		version = ngtcp2_conn_get_negotiated_version(conn->inner);
+#endif /* NGTCP2_VERSION_NUM >= 0x011700 */
+		if (is_server && (version == 0)) {
+			RETERR(isc__quic_set_remote_transport_params(conn,
+								     tls));
+		}
+		FALLTHROUGH;
+	case NGTCP2_ENCRYPTION_LEVEL_1RTT:
+#if NGTCP2_VERSION_NUM >= 0x011700 /* 1.23.0 */
+		version = ngtcp2_conn_get_negotiated_version2(conn->inner);
+		ngctx = ngtcp2_conn_get_crypto_ctx2(conn->inner);
+#else  /* NGTCP2_VERSION_NUM >= 0x011700 */
+		version = ngtcp2_conn_get_negotiated_version(conn->inner);
+		ngctx = ngtcp2_conn_get_crypto_ctx(conn->inner);
+#endif /* NGTCP2_VERSION_NUM >= 0x011700 */
+		if (ngctx->aead.native_handle == NULL) {
+			cipher = SSL_get_current_cipher(tls);
+			if (cipher == NULL) {
+				CLEANUP(ISC_R_FAILURE);
+			}
+
+			switch (SSL_CIPHER_get_id(cipher)) {
+			case TLS1_3_CK_AES_128_GCM_SHA256:
+				ngctx = &initial_aes128gcm_sha256_ctx;
+				break;
+			case TLS1_3_CK_AES_256_GCM_SHA384:
+				ngctx = &initial_aes256_sha384_ctx;
+				break;
+			case TLS1_3_CK_CHACHA20_POLY1305_SHA256:
+				ngctx = &initial_chacha20poly1305_sha256_ctx;
+				break;
+			default:
+				UNREACHABLE();
+			}
+
+			ngtcp2_conn_set_crypto_ctx(conn->inner, ngctx);
+#if NGTCP2_VERSION_NUM >= 0x011700 /* 1.23.0 */
+			ngctx = ngtcp2_conn_get_crypto_ctx2(conn->inner);
+#else  /* NGTCP2_VERSION_NUM >= 0x011700 */
+			ngctx = ngtcp2_conn_get_crypto_ctx(conn->inner);
+#endif /* NGTCP2_VERSION_NUM >= 0x011700 */
+		}
+		break;
+	default:
+		UNREACHABLE();
+	}
+
+	aead_algorithm = (uintptr_t)(void *)ngctx->aead.native_handle;
+	switch (aead_algorithm) {
+	case ISC_CRYPTO_AEAD_ALGORITHM_AES128GCM:
+		hp_algorithm = ISC_CRYPTO_QUIC_HP_PROTECT_ALGORITHM_AES128;
+		key_len = isc_crypto_aes128gcm_key_length;
+		nonce_len = isc_crypto_aes128gcm_nonce_length;
+		break;
+	case ISC_CRYPTO_AEAD_ALGORITHM_AES256GCM:
+		hp_algorithm = ISC_CRYPTO_QUIC_HP_PROTECT_ALGORITHM_AES256;
+		key_len = isc_crypto_aes256gcm_key_length;
+		nonce_len = isc_crypto_aes256gcm_nonce_length;
+		break;
+	case ISC_CRYPTO_AEAD_ALGORITHM_CHACHA20POLY1305:
+		hp_algorithm = ISC_CRYPTO_QUIC_HP_PROTECT_ALGORITHM_CHACHA20;
+		key_len = isc_crypto_chacha20poly1305_key_length;
+		nonce_len = isc_crypto_chacha20poly1305_nonce_length;
+		break;
+	default:
+		UNREACHABLE();
+	}
+
+	md = (uintptr_t)(void *)ngctx->md.native_handle;
+	hp_algorithm = (uintptr_t)(void *)ngctx->hp.native_handle;
+
+	CHECK(derive_packet_keys(version, md, secret,
+				 (isc_region_t){ key_len, key_buffer },
+				 (isc_region_t){ nonce_len, nonce_buffer },
+				 (isc_region_t){ key_len, hp_buffer }));
+
+	CHECK(isc_crypto_aead_create(aead_algorithm,
+				     (isc_constregion_t){ key_len, key_buffer },
+				     ISC_CRYPTO_AEAD_DIRECTION_OPEN, &aead));
+	ng_aead_ctx = (ngtcp2_crypto_aead_ctx){
+		.native_handle = MOVE_OWNERSHIP(aead),
+	};
+
+	CHECK(isc_crypto_quic_hp_protect_create(
+		isc_g_mctx, (isc_constregion_t){ key_len, hp_buffer },
+		hp_algorithm, &hp));
+	ng_hp_ctx = (ngtcp2_crypto_cipher_ctx){
+		.native_handle = MOVE_OWNERSHIP(hp),
+	};
+
+	switch (nglevel) {
+	case NGTCP2_ENCRYPTION_LEVEL_0RTT:
+		if (ngtcp2_conn_install_0rtt_key(conn->inner, &ng_aead_ctx,
+						 nonce_buffer, nonce_len,
+						 &ng_hp_ctx) != 0)
+		{
+			CLEANUP(ISC_R_FAILURE);
+		}
+		break;
+	case NGTCP2_ENCRYPTION_LEVEL_HANDSHAKE:
+		if (ngtcp2_conn_install_rx_handshake_key(
+			    conn->inner, &ng_aead_ctx, nonce_buffer, nonce_len,
+			    &ng_hp_ctx) != 0)
+		{
+			CLEANUP(ISC_R_FAILURE);
+		}
+		break;
+	case NGTCP2_ENCRYPTION_LEVEL_1RTT:
+		if (!is_server) {
+			CHECK(isc__quic_set_remote_transport_params(conn, tls));
+		}
+
+		if (ngtcp2_conn_install_rx_key(conn->inner, secret.base,
+					       secret.length, &ng_aead_ctx,
+					       nonce_buffer, nonce_len,
+					       &ng_hp_ctx) != 0)
+		{
+			CLEANUP(ISC_R_FAILURE);
+		}
+		break;
+	default:
+		UNREACHABLE();
+	}
+
+	isc_safe_memwipe(key_buffer, sizeof(key_buffer));
+	isc_safe_memwipe(nonce_buffer, sizeof(nonce_buffer));
+	isc_safe_memwipe(hp_buffer, sizeof(hp_buffer));
+	return ISC_R_SUCCESS;
+
+cleanup:
+	if (aead != NULL) {
+		isc_crypto_aead_destroy(&aead);
+	}
+
+	if (ng_aead_ctx.native_handle != NULL) {
+		aead = ng_aead_ctx.native_handle;
+		isc_crypto_aead_destroy(&aead);
+	}
+
+	if (hp != NULL) {
+		isc_crypto_quic_hp_protect_destroy(&hp);
+	}
+
+	if (ng_hp_ctx.native_handle != NULL) {
+		hp = ng_hp_ctx.native_handle;
+		isc_crypto_quic_hp_protect_destroy(&hp);
+	}
+
+	isc_safe_memwipe(key_buffer, sizeof(key_buffer));
+	isc_safe_memwipe(nonce_buffer, sizeof(nonce_buffer));
+	isc_safe_memwipe(hp_buffer, sizeof(hp_buffer));
+
+	return result;
+}
+
+isc_result_t
+isc__quic_setup_write_key(isc_quic_conn_t *conn, bool is_server,
+			  ngtcp2_encryption_level nglevel,
+			  isc_constregion_t secret) {
+	isc_crypto_quic_hp_protect_algorithm_t hp_algorithm;
+	isc_crypto_aead_algorithm_t aead_algorithm;
+	isc_crypto_quic_hp_protect_t *hp = NULL;
+	ngtcp2_crypto_cipher_ctx ng_hp_ctx = { 0 };
+	const ngtcp2_crypto_ctx *ngctx;
+	ngtcp2_crypto_aead_ctx ng_aead_ctx = { 0 };
+	isc_crypto_aead_t *aead = NULL;
+	const SSL_CIPHER *cipher;
+	isc_md_type_t md;
+	isc_result_t result;
+	isc_tls_t *tls;
+	uint32_t version;
+	size_t key_len, nonce_len;
+	uint8_t key_buffer[ISC_CRYPTO_AEAD_MAX_KEY_LENGTH],
+		nonce_buffer[ISC_CRYPTO_AEAD_MAX_NONCE_LENGTH],
+		hp_buffer[ISC_CRYPTO_AEAD_MAX_KEY_LENGTH];
+
+#if NGTCP2_VERSION_NUM >= 0x011700 /* 1.23.0 */
+	tls = ngtcp2_conn_get_tls_native_handle2(conn->inner);
+#else  /* NGTCP2_VERSION_NUM >= 0x011700 */
+	tls = ngtcp2_conn_get_tls_native_handle(conn->inner);
+#endif /* NGTCP2_VERSION_NUM >= 0x011700 */
+
+	switch (nglevel) {
+	case NGTCP2_ENCRYPTION_LEVEL_0RTT:
+		if (!is_server) {
+			return ISC_R_SUCCESS;
+		}
+
+		cipher = SSL_get_current_cipher(tls);
+		if (cipher == NULL) {
+			return ISC_R_FAILURE;
+		}
+
+		switch (SSL_CIPHER_get_id(cipher)) {
+		case TLS1_3_CK_AES_128_GCM_SHA256:
+			ngctx = &initial_aes128gcm_sha256_ctx;
+			break;
+		case TLS1_3_CK_AES_256_GCM_SHA384:
+			ngctx = &initial_aes256_sha384_ctx;
+			break;
+		case TLS1_3_CK_CHACHA20_POLY1305_SHA256:
+			ngctx = &initial_chacha20poly1305_sha256_ctx;
+			break;
+		default:
+			UNREACHABLE();
+		}
+
+		ngtcp2_conn_set_0rtt_crypto_ctx(conn->inner, ngctx);
+#if NGTCP2_VERSION_NUM >= 0x011700 /* 1.23.0 */
+		ngctx = ngtcp2_conn_get_0rtt_crypto_ctx2(conn->inner);
+		version = ngtcp2_conn_get_client_chosen_version2(conn->inner);
+#else  /* NGTCP2_VERSION_NUM >= 0x011700 */
+		ngctx = ngtcp2_conn_get_0rtt_crypto_ctx(conn->inner);
+		version = ngtcp2_conn_get_client_chosen_version(conn->inner);
+#endif /* NGTCP2_VERSION_NUM >= 0x011700 */
+		break;
+	case NGTCP2_ENCRYPTION_LEVEL_HANDSHAKE:
+#if NGTCP2_VERSION_NUM >= 0x011700 /* 1.23.0 */
+		version = ngtcp2_conn_get_negotiated_version2(conn->inner);
+#else  /* NGTCP2_VERSION_NUM >= 0x011700 */
+		version = ngtcp2_conn_get_negotiated_version(conn->inner);
+#endif /* NGTCP2_VERSION_NUM >= 0x011700 */
+
+		if (is_server && (version == 0)) {
+			RETERR(isc__quic_set_remote_transport_params(conn,
+								     tls));
+		}
+		FALLTHROUGH;
+	case NGTCP2_ENCRYPTION_LEVEL_1RTT:
+#if NGTCP2_VERSION_NUM >= 0x011700 /* 1.23.0 */
+		version = ngtcp2_conn_get_negotiated_version2(conn->inner);
+		ngctx = ngtcp2_conn_get_crypto_ctx2(conn->inner);
+#else  /* NGTCP2_VERSION_NUM >= 0x011700 */
+		version = ngtcp2_conn_get_negotiated_version(conn->inner);
+		ngctx = ngtcp2_conn_get_crypto_ctx(conn->inner);
+#endif /* NGTCP2_VERSION_NUM >= 0x011700 */
+		if (ngctx->aead.native_handle == NULL) {
+			cipher = SSL_get_current_cipher(tls);
+			if (cipher == NULL) {
+				CLEANUP(ISC_R_FAILURE);
+			}
+
+			switch (SSL_CIPHER_get_id(cipher)) {
+			case TLS1_3_CK_AES_128_GCM_SHA256:
+				ngctx = &initial_aes128gcm_sha256_ctx;
+				break;
+			case TLS1_3_CK_AES_256_GCM_SHA384:
+				ngctx = &initial_aes256_sha384_ctx;
+				break;
+			case TLS1_3_CK_CHACHA20_POLY1305_SHA256:
+				ngctx = &initial_chacha20poly1305_sha256_ctx;
+				break;
+			default:
+				UNREACHABLE();
+			}
+
+			ngtcp2_conn_set_crypto_ctx(conn->inner, ngctx);
+#if NGTCP2_VERSION_NUM >= 0x011700 /* 1.23.0 */
+			ngctx = ngtcp2_conn_get_crypto_ctx2(conn->inner);
+#else  /* NGTCP2_VERSION_NUM >= 0x011700 */
+			ngctx = ngtcp2_conn_get_crypto_ctx(conn->inner);
+#endif /* NGTCP2_VERSION_NUM >= 0x011700 */
+		}
+		break;
+	default:
+		UNREACHABLE();
+	}
+
+	aead_algorithm = (uintptr_t)(void *)ngctx->aead.native_handle;
+	switch (aead_algorithm) {
+	case ISC_CRYPTO_AEAD_ALGORITHM_AES128GCM:
+		hp_algorithm = ISC_CRYPTO_QUIC_HP_PROTECT_ALGORITHM_AES128;
+		key_len = isc_crypto_aes128gcm_key_length;
+		nonce_len = isc_crypto_aes128gcm_nonce_length;
+		break;
+	case ISC_CRYPTO_AEAD_ALGORITHM_AES256GCM:
+		hp_algorithm = ISC_CRYPTO_QUIC_HP_PROTECT_ALGORITHM_AES256;
+		key_len = isc_crypto_aes256gcm_key_length;
+		nonce_len = isc_crypto_aes256gcm_nonce_length;
+		break;
+	case ISC_CRYPTO_AEAD_ALGORITHM_CHACHA20POLY1305:
+		hp_algorithm = ISC_CRYPTO_QUIC_HP_PROTECT_ALGORITHM_CHACHA20;
+		key_len = isc_crypto_chacha20poly1305_key_length;
+		nonce_len = isc_crypto_chacha20poly1305_nonce_length;
+		break;
+	default:
+		UNREACHABLE();
+	}
+
+	md = (uintptr_t)(void *)ngctx->md.native_handle;
+	hp_algorithm = (uintptr_t)(void *)ngctx->hp.native_handle;
+
+	CHECK(derive_packet_keys(version, md, secret,
+				 (isc_region_t){ key_len, key_buffer },
+				 (isc_region_t){ nonce_len, nonce_buffer },
+				 (isc_region_t){ key_len, hp_buffer }));
+
+	CHECK(isc_crypto_aead_create(aead_algorithm,
+				     (isc_constregion_t){ key_len, key_buffer },
+				     ISC_CRYPTO_AEAD_DIRECTION_SEAL, &aead));
+	ng_aead_ctx = (ngtcp2_crypto_aead_ctx){
+		.native_handle = MOVE_OWNERSHIP(aead),
+	};
+
+	CHECK(isc_crypto_quic_hp_protect_create(
+		isc_g_mctx, (isc_constregion_t){ key_len, hp_buffer },
+		hp_algorithm, &hp));
+	ng_hp_ctx = (ngtcp2_crypto_cipher_ctx){
+		.native_handle = MOVE_OWNERSHIP(hp),
+	};
+
+	switch (nglevel) {
+	case NGTCP2_ENCRYPTION_LEVEL_0RTT:
+		if (ngtcp2_conn_install_0rtt_key(conn->inner, &ng_aead_ctx,
+						 nonce_buffer, nonce_len,
+						 &ng_hp_ctx) != 0)
+		{
+			CLEANUP(ISC_R_FAILURE);
+		}
+		break;
+	case NGTCP2_ENCRYPTION_LEVEL_HANDSHAKE:
+		if (ngtcp2_conn_install_tx_handshake_key(
+			    conn->inner, &ng_aead_ctx, nonce_buffer, nonce_len,
+			    &ng_hp_ctx) != 0)
+		{
+			CLEANUP(ISC_R_FAILURE);
+		}
+
+		if (is_server) {
+			CHECK(isc__quic_set_local_transport_params(conn, tls));
+		}
+		break;
+	case NGTCP2_ENCRYPTION_LEVEL_1RTT:
+		if (ngtcp2_conn_install_tx_key(conn->inner, secret.base,
+					       secret.length, &ng_aead_ctx,
+					       nonce_buffer, nonce_len,
+					       &ng_hp_ctx) != 0)
+		{
+			CLEANUP(ISC_R_FAILURE);
+		}
+		break;
+	default:
+		UNREACHABLE();
+	}
+
+	isc_safe_memwipe(key_buffer, sizeof(key_buffer));
+	isc_safe_memwipe(nonce_buffer, sizeof(nonce_buffer));
+	isc_safe_memwipe(hp_buffer, sizeof(hp_buffer));
+	return ISC_R_SUCCESS;
+
+cleanup:
+	if (aead != NULL) {
+		isc_crypto_aead_destroy(&aead);
+	}
+
+	if (ng_aead_ctx.native_handle != NULL) {
+		aead = ng_aead_ctx.native_handle;
+		isc_crypto_aead_destroy(&aead);
+	}
+
+	if (hp != NULL) {
+		isc_crypto_quic_hp_protect_destroy(&hp);
+	}
+
+	if (ng_hp_ctx.native_handle != NULL) {
+		hp = ng_hp_ctx.native_handle;
+		isc_crypto_quic_hp_protect_destroy(&hp);
+	}
+
+	isc_safe_memwipe(key_buffer, sizeof(key_buffer));
+	isc_safe_memwipe(nonce_buffer, sizeof(nonce_buffer));
+	isc_safe_memwipe(hp_buffer, sizeof(hp_buffer));
+
+	return result;
+}
+
 ISC_REFCOUNT_IMPL(isc_quic_conn, destroy);

 isc_result_t
diff --git a/lib/isc/quic/meson.build b/lib/isc/quic/meson.build
index 2223bd501f..65ad1820f6 100644
--- a/lib/isc/quic/meson.build
+++ b/lib/isc/quic/meson.build
@@ -16,3 +16,9 @@ isc_srcset.add(
         'router.c',
     ),
 )
+
+isc_srcset.add(
+    when: 'HAVE_OPENSSL_3',
+    if_true: files('ossl.c'),
+    if_false: files('boringlike.c'),
+)
diff --git a/lib/isc/quic/ossl.c b/lib/isc/quic/ossl.c
new file mode 100644
index 0000000000..e95fb2b2dc
--- /dev/null
+++ b/lib/isc/quic/ossl.c
@@ -0,0 +1,423 @@
+/*
+ * Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+ *
+ * SPDX-License-Identifier: MPL-2.0
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * See the COPYRIGHT file distributed with this work for additional
+ * information regarding copyright ownership.
+ */
+
+#include <stdint.h>
+
+#include <ngtcp2/ngtcp2.h>
+#include <openssl/core_dispatch.h>
+#include <openssl/err.h>
+#include <openssl/ssl.h>
+
+#include <isc/crypto.h>
+#include <isc/ossl_wrap.h>
+
+#include "quic_p.h" /* IWYU pragma: keep */
+
+/**
+ * \brief
+ * Lookup table for translating a OpenSSL encryption level to its ngtcp2
+ * counterpart.
+ */
+static ngtcp2_encryption_level libssl_to_ngtcp2_level_lut[] = {
+	/* clang-format off */
+	[OSSL_RECORD_PROTECTION_LEVEL_NONE] = NGTCP2_ENCRYPTION_LEVEL_INITIAL,
+	[OSSL_RECORD_PROTECTION_LEVEL_EARLY] = NGTCP2_ENCRYPTION_LEVEL_0RTT,
+	[OSSL_RECORD_PROTECTION_LEVEL_HANDSHAKE] = NGTCP2_ENCRYPTION_LEVEL_HANDSHAKE,
+	[OSSL_RECORD_PROTECTION_LEVEL_APPLICATION] = NGTCP2_ENCRYPTION_LEVEL_1RTT,
+	/* clang-format on */
+};
+
+/**
+ * \brief
+ * `SSL` object index to point back to its `isc_quic_conn_t`
+ *
+ * This is only really used inside the ALPN selection callback since the
+ * callback argument is global for all connections created by the same
+ * `SSL_CTX`.
+ */
+static int quic_index = -1;
+
+/*
+ * https://docs.openssl.org/master/man3/SSL_set_quic_tls_cbs/
+ */
+
+static int
+crypto_send_cb(isc_tls_t *tls, const uint8_t *buf, size_t len, size_t *consumed,
+	       void *arg);
+
+static int
+crypto_recv_rcd_cb(isc_tls_t *tls, const unsigned char **buf, size_t *read,
+		   void *arg);
+
+static int
+crypto_release_rcd_cb(isc_tls_t *tls, size_t released, void *arg);
+
+static int
+yield_secret_cb(isc_tls_t *tls, uint32_t prot_level, int direction,
+		const unsigned char *secret, size_t len, void *arg);
+
+static int
+got_transport_params_cb(isc_tls_t *tls, const uint8_t *params, size_t len,
+			void *arg);
+
+static int
+alert_cb(isc_tls_t *tls, uint8_t code, void *arg);
+
+static const OSSL_DISPATCH dispatch[] = {
+	{
+		.function_id = OSSL_FUNC_SSL_QUIC_TLS_CRYPTO_SEND,
+		.function = (OSSL_FUNC)crypto_send_cb,
+	},
+	{
+		.function_id = OSSL_FUNC_SSL_QUIC_TLS_YIELD_SECRET,
+		.function = (OSSL_FUNC)yield_secret_cb,
+	},
+	{
+		.function_id = OSSL_FUNC_SSL_QUIC_TLS_CRYPTO_RECV_RCD,
+		.function = (OSSL_FUNC)crypto_recv_rcd_cb,
+	},
+	{
+		.function_id = OSSL_FUNC_SSL_QUIC_TLS_CRYPTO_RELEASE_RCD,
+		.function = (OSSL_FUNC)crypto_release_rcd_cb,
+	},
+	{
+		.function_id = OSSL_FUNC_SSL_QUIC_TLS_GOT_TRANSPORT_PARAMS,
+		.function = (OSSL_FUNC)got_transport_params_cb,
+	},
+	{
+		.function_id = OSSL_FUNC_SSL_QUIC_TLS_ALERT,
+		.function = (OSSL_FUNC)alert_cb,
+	},
+	{
+		.function_id = 0,
+		.function = NULL,
+	},
+};
+
+isc_result_t
+isc__quic_setup_tls(isc_tls_t *tls, isc_quic_conn_t *conn) {
+	SSL_set_ex_data(tls, quic_index, conn);
+
+	/*
+	 * There is no SSL_CTX_set_quic_tls_cbs
+	 */
+	if (SSL_set_quic_tls_cbs(tls, dispatch, conn) != 1) {
+		return ISC_R_TLSERROR;
+	}
+
+	return ISC_R_SUCCESS;
+}
+
+static int
+crypto_send_cb(isc_tls_t *tls ISC_ATTR_UNUSED, const uint8_t *buf, size_t len,
+	       size_t *consumed, void *arg) {
+	isc_quic_conn_t *conn;
+	int r;
+
+	conn = arg;
+	r = ngtcp2_conn_submit_crypto_data(conn->inner, conn->level, buf, len);
+	if (r != 0) {
+		ngtcp2_conn_set_tls_error(conn->inner, r);
+		return 0;
+	}
+
+	*consumed = len;
+
+	return 1;
+}
+
+static int
+crypto_recv_rcd_cb(isc_tls_t *tls ISC_ATTR_UNUSED, const unsigned char **bufp,
+		   size_t *readp, void *arg) {
+	isc__quic_crypto_frame_data_t *frame;
+	isc_quic_conn_t *conn;
+
+	conn = arg;
+	if (conn == NULL) {
+		*bufp = NULL;
+		*readp = 0;
+		return 1;
+	}
+
+	frame = ISC_LIST_HEAD(conn->crypto_buffered_frames);
+	if (frame == NULL) {
+		*bufp = NULL;
+		*readp = 0;
+		return 1;
+	}
+	ISC_LIST_DEQUEUE(conn->crypto_buffered_frames, frame, link);
+	ISC_LIST_ENQUEUE(conn->crypto_awaiting_frames, frame, link);
+
+	*bufp = frame->data;
+	*readp = frame->len;
+
+	return 1;
+}
+
+static int
+crypto_release_rcd_cb(isc_tls_t *tls ISC_ATTR_UNUSED, size_t released,
+		      void *arg) {
+	isc__quic_crypto_frame_data_t *frame;
+	isc_quic_conn_t *conn;
+
+	conn = arg;
+	INSIST(conn != NULL);
+
+	if (released == 0) {
+		return 1;
+	}
+
+	frame = ISC_LIST_HEAD(conn->crypto_awaiting_frames);
+	INSIST(frame != NULL);
+	ISC_LIST_DEQUEUE(conn->crypto_awaiting_frames, frame, link);
+	isc_mem_put(conn->mem.user_data, frame,
+		    STRUCT_FLEX_SIZE(frame, data, frame->len));
+
+	return 1;
+}
+
+static int
+yield_secret_cb(isc_tls_t *tls ISC_ATTR_UNUSED, uint32_t level, int direction,
+		const unsigned char *secret, size_t len, void *arg) {
+	ngtcp2_encryption_level nglevel;
+	isc_quic_conn_t *conn;
+	isc_result_t result;
+	bool is_server;
+
+	nglevel = libssl_to_ngtcp2_level_lut[level];
+	conn = arg;
+
+#if NGTCP2_VERSION_NUM >= 0x011700 /* 1.23.0 */
+	is_server = ngtcp2_conn_is_server2(conn->inner);
+#else  /* NGTCP2_VERSION_NUM >= 0x011700 */
+	is_server = ngtcp2_conn_is_server(conn->inner);
+#endif /* NGTCP2_VERSION_NUM >= 0x011700 */
+
+	switch (direction) {
+	case 0:
+		result = isc__quic_setup_read_key(
+			conn, is_server, nglevel,
+			(isc_constregion_t){ len, secret });
+		if (result != ISC_R_SUCCESS) {
+			return 0;
+		}
+		break;
+	case 1:
+		result = isc__quic_setup_write_key(
+			conn, is_server, nglevel,
+			(isc_constregion_t){ len, secret });
+		if (result != ISC_R_SUCCESS) {
+			return 0;
+		}
+		conn->level = nglevel;
+		break;
+	default:
+		UNREACHABLE();
+	}
+
+	return 1;
+}
+
+static int
+got_transport_params_cb(isc_tls_t *tls ISC_ATTR_UNUSED, const uint8_t *params,
+			size_t len, void *arg) {
+	isc_quic_conn_t *conn;
+	int r;
+
+	conn = arg;
+	r = ngtcp2_conn_decode_and_set_remote_transport_params(conn->inner,
+							       params, len);
+	if (r != 0) {
+		ngtcp2_conn_set_tls_error(conn->inner, r);
+		return 0;
+	}
+
+	return 1;
+}
+
+static int
+alert_cb(isc_tls_t *tls ISC_ATTR_UNUSED, uint8_t code, void *arg) {
+	isc_quic_conn_t *conn = arg;
+	ngtcp2_conn_set_tls_alert(conn->inner, code);
+	return 1;
+}
+
+static int
+quic_alpn_select(isc_tls_t *tls, const unsigned char **outp,
+		 unsigned char *lenp, const unsigned char *in, unsigned int len,
+		 void *arg ISC_ATTR_UNUSED) {
+	isc_quic_conn_t *conn = SSL_get_ex_data(tls, quic_index);
+	INSIST(conn != NULL);
+
+	int r = SSL_select_next_proto(UNCONST(outp), lenp, in, len,
+				      conn->alpn.data, conn->alpn.len);
+	if (r != OPENSSL_NPN_NEGOTIATED) {
+		return SSL_TLSEXT_ERR_ALERT_FATAL;
+	}
+
+	return SSL_TLSEXT_ERR_OK;
+}
+
+/*
+ * This is a no-op here because OpenSSL expects the remote transport parameters
+ * to be set inside the `got_transport_params_cb`.
+ */
+isc_result_t
+isc__quic_set_remote_transport_params(isc_quic_conn_t *conn ISC_ATTR_UNUSED,
+				      isc_tls_t *tls ISC_ATTR_UNUSED) {
+	return ISC_R_SUCCESS;
+}
+
+isc_result_t
+isc__quic_set_local_transport_params(isc_quic_conn_t *conn, isc_tls_t *tls) {
+	/*
+	 * I don't think there is a well defined limit but I see that 256 bytes
+	 * is common in the wild so this should be good enough
+	 *
+	 * (famous last words)
+	 *
+	 * - Aydin
+	 */
+	uint8_t buffer[512];
+	ngtcp2_ssize len;
+	int r;
+
+	INSIST(conn->local_transport_params == NULL);
+
+#if NGTCP2_VERSION_NUM >= 0x011700 /* 1.23.0 */
+	len = ngtcp2_conn_encode_local_transport_params2(conn->inner, buffer,
+							 sizeof(buffer));
+#else
+	len = ngtcp2_conn_encode_local_transport_params(conn->inner, buffer,
+							sizeof(buffer));
+#endif
+	if (len < 0) {
+		return ISC_R_NOSPACE;
+	}
+
+	conn->local_transport_params = isc_mem_allocate(conn->mem.user_data,
+							len);
+	memcpy(conn->local_transport_params, buffer, len);
+
+	r = SSL_set_quic_tls_transport_params(tls, conn->local_transport_params,
+					      len);
+	if (r != 1) {
+		return isc_ossl_wrap_logged_toresult(
+			ISC_LOGCATEGORY_GENERAL, ISC_LOGMODULE_CRYPTO,
+			"SSL_set_quic_tls_transport_params", ISC_R_TLSERROR);
+	}
+
+	return ISC_R_SUCCESS;
+}
+
+isc_result_t
+isc__quic_do_tls(isc_quic_conn_t *conn, isc_tls_t *tls,
+		 ngtcp2_encryption_level nglevel ISC_ATTR_UNUSED,
+		 isc_constregion_t data) {
+	isc__quic_crypto_frame_data_t *frame;
+	isc_result_t result;
+	bool need_handshake;
+	int r;
+
+	ERR_set_mark();
+
+	if (data.length != 0) {
+		frame = isc_mem_get(conn->mem.user_data,
+				    STRUCT_FLEX_SIZE(frame, data, data.length));
+		*frame = (isc__quic_crypto_frame_data_t){
+			.len = data.length,
+			.link = ISC_LINK_INITIALIZER,
+		};
+		memcpy(frame->data, data.base, data.length);
+		ISC_LIST_ENQUEUE(conn->crypto_buffered_frames, frame, link);
+	}
+
+#if NGTCP2_VERSION_NUM >= 0x011700 /* 1.23.0 */
+	need_handshake = ngtcp2_conn_get_handshake_completed2(conn->inner) == 0;
+#else
+	need_handshake = ngtcp2_conn_get_handshake_completed(conn->inner) == 0;
+#endif
+
+	if (need_handshake) {
+		r = SSL_do_handshake(tls);
+		if (r <= 0) {
+			switch (SSL_get_error(tls, r)) {
+			case SSL_ERROR_WANT_READ:
+			case SSL_ERROR_WANT_WRITE:
+			case SSL_ERROR_WANT_X509_LOOKUP:
+			case SSL_ERROR_WANT_CLIENT_HELLO_CB:
+				CLEANUP(ISC_R_SUCCESS);
+			default:
+				CLEANUP(ISC_R_TLSERROR);
+			}
+		}
+
+		ngtcp2_conn_tls_handshake_completed(conn->inner);
+	}
+
+	r = SSL_read(tls, NULL, 0);
+	if (r != 1) {
+		switch (SSL_get_error(tls, r)) {
+		case SSL_ERROR_WANT_READ:
+		case SSL_ERROR_WANT_WRITE:
+			CLEANUP(ISC_R_SUCCESS);
+		default:
+			CLEANUP(ISC_R_TLSERROR);
+		}
+	}
+
+	result = ISC_R_SUCCESS;
+
+cleanup:
+	ERR_pop_to_mark();
+	return result;
+}
+
+isc_result_t
+isc_quic_tlsctx_client_configure(isc_tlsctx_t *tlsctx) {
+	REQUIRE(tlsctx != NULL);
+
+	if (SSL_CTX_set_min_proto_version(tlsctx, TLS1_3_VERSION) != 1) {
+		return isc_ossl_wrap_logged_toresult(
+			ISC_LOGCATEGORY_GENERAL, ISC_LOGMODULE_CRYPTO,
+			"SSL_CTX_set_min_proto_version", ISC_R_TLSERROR);
+	}
+
+	return ISC_R_SUCCESS;
+}
+
+isc_result_t
+isc_quic_tlsctx_server_configure(isc_tlsctx_t *tlsctx) {
+	REQUIRE(tlsctx != NULL);
+
+	SSL_CTX_set_alpn_select_cb(tlsctx, quic_alpn_select, NULL);
+
+	if (SSL_CTX_set_min_proto_version(tlsctx, TLS1_3_VERSION) != 1) {
+		return isc_ossl_wrap_logged_toresult(
+			ISC_LOGCATEGORY_GENERAL, ISC_LOGMODULE_CRYPTO,
+			"SSL_CTX_set_min_proto_version", ISC_R_TLSERROR);
+	}
+
+	return ISC_R_SUCCESS;
+}
+
+void
+isc__quic_initialize(void) {
+	quic_index = SSL_get_ex_new_index(0, NULL, NULL, NULL, NULL);
+	INSIST(quic_index != -1);
+}
+
+void
+isc__quic_shutdown(void) {
+	/* noop */
+}
diff --git a/lib/isc/quic/quic_p.h b/lib/isc/quic/quic_p.h
index 4f16c5480a..ecb8329a20 100644
--- a/lib/isc/quic/quic_p.h
+++ b/lib/isc/quic/quic_p.h
@@ -21,14 +21,114 @@
 #include <isc/quic.h>
 #include <isc/types.h>

+typedef struct isc__quic_stream isc__quic_stream_t;
+typedef struct isc__quic_stream_data isc__quic_stream_data_t;
+
+#ifdef HAVE_OPENSSL_3
+typedef struct isc__quic_crypto_frame_data isc__quic_crypto_frame_data_t;
+#endif /* HAVE_OPENSSL_3 */
+
+typedef enum isc__quic_conn_state {
+	QUIC_CONN_STATE_INVALID = 0x00,
+	QUIC_CONN_STATE_HANDSHAKE = 0x01,
+	QUIC_CONN_STATE_CONNECTED = 0x02,
+	QUIC_CONN_STATE_CLOSED = 0x03,
+	QUIC_CONN_STATE_TERMINATED = 0x04,
+} isc__quic_conn_state_t;
+
 struct isc_quic_conn {
 	uint32_t magic;
 	isc_refcount_t references;
+
 	isc_quic_router_t *router;

 	const isc_quic_conn_callbacks_t *cb;
 	void *cbarg;

+	ISC_LIST(isc__quic_stream_t) streams;
+	ISC_LIST(isc__quic_stream_data_t) outgoing_stream_data;
+
 	/* isc_mem_t resides inside `mem.user_data` */
 	ngtcp2_mem mem;
+
+	/*
+	 * This fits "\x3doq\x2h3" and fits perfectly with the struct's 8 byte
+	 * member alignment.
+	 */
+	struct {
+		uint8_t len;
+		uint8_t data[7];
+	} alpn;
+
+	isc__quic_conn_state_t state;
+
+/*
+ * OpenSSL (not the forks) wants us to keep some information alive and
+ * allocated.
+ */
+#ifdef HAVE_OPENSSL_3
+	ngtcp2_encryption_level level;
+	uint8_t *local_transport_params;
+	ISC_LIST(isc__quic_crypto_frame_data_t) crypto_buffered_frames;
+	ISC_LIST(isc__quic_crypto_frame_data_t) crypto_awaiting_frames;
+#endif /* HAVE_OPENSSL_3 */
+
+	ngtcp2_conn *inner;
 };
+
+#ifdef HAVE_OPENSSL_3
+struct isc__quic_crypto_frame_data {
+	size_t len;
+	ISC_LINK(isc__quic_crypto_frame_data_t) link;
+	uint8_t data[] ISC_ATTR_COUNTED_BY(len);
+};
+#endif /* HAVE_OPENSSL_3 */
+
+isc_result_t
+isc__quic_setup_read_key(isc_quic_conn_t *conn, bool is_server,
+			 ngtcp2_encryption_level nglevel,
+			 isc_constregion_t secret);
+/**<
+ * \brief
+ * Derive and install key for RX operations.
+ *
+ * \retval ISC_R_SUCCESS on success
+ * \retval ISC_R_FAILURE on opaque failure
+ */
+
+isc_result_t
+isc__quic_setup_write_key(isc_quic_conn_t *conn, bool is_server,
+			  ngtcp2_encryption_level nglevel,
+			  isc_constregion_t secret);
+/**<
+ * \brief
+ * Derive and install key for TX operations.
+ *
+ * \retval ISC_R_SUCCESS on success
+ * \retval ISC_R_FAILURE on opaque failure
+ */
+
+isc_result_t
+isc__quic_set_remote_transport_params(isc_quic_conn_t *conn, isc_tls_t *tls);
+
+isc_result_t
+isc__quic_set_local_transport_params(isc_quic_conn_t *conn, isc_tls_t *tls);
+
+isc_result_t
+isc__quic_setup_tls(isc_tls_t *tls, isc_quic_conn_t *conn);
+/**<
+ * \brief
+ * Setup for the connection.
+ *
+ * \retval ISC_R_SUCCESS on success.
+ */
+
+isc_result_t
+isc__quic_do_tls(isc_quic_conn_t *conn, isc_tls_t *tls,
+		 ngtcp2_encryption_level nglevel, isc_constregion_t data);
+/**<
+ * \brief
+ * Drive the TLS integration of the connection.
+ *
+ * \retval ISC_R_SUCCESS on success
+ */
diff --git a/meson.build b/meson.build
index f1533e906f..041e41bfb8 100644
--- a/meson.build
+++ b/meson.build
@@ -670,10 +670,28 @@ config.set('OPENSSL_NO_DEPRECATED', true)
 if openssl_version_number.version_compare('>=3.0.0')
     config.set('HAVE_OPENSSL_3', true)
     config.set('OPENSSL_API_COMPAT', 30000)
+
+    config.set(
+        'HAVE_OPENSSL_QUIC_INTEGRATION',
+        cc.has_function(
+            'SSL_set_quic_tls_cbs',
+            prefix: '#include <openssl/ssl.h>',
+            dependencies: openssl_dep,
+        ),
+    )
 else
     config.set('HAVE_OPENSSL_3', false)
     config.set('OPENSSL_API_COMPAT', 10100)

+    config.set(
+        'HAVE_OPENSSL_QUIC_INTEGRATION',
+        cc.has_function(
+            'SSL_set_quic_method',
+            prefix: '#include <openssl/ssl.h>',
+            dependencies: openssl_dep,
+        ),
+    )
+
     have_openssl_aead_h = cc.has_header('openssl/aead.h', dependencies: openssl_dep)
     config.set('HAVE_OPENSSL_AEAD_H', have_openssl_aead_h)
     if have_openssl_aead_h
@@ -862,7 +880,7 @@ endif

 ## DNS-over-QUIC (DoQ) [IN PROGRESS, DEVELOPER MODE ONLY]
 ngtcp2_dep = null_dep
-if developer_mode
+if developer_mode and config.get('HAVE_OPENSSL_QUIC_INTEGRATION')
     ngtcp2_dep = dependency('libngtcp2', version: '>=1.11.0', required: false)
     config.set('HAVE_LIBNGTCP2', ngtcp2_dep.found())
 endif