From: Frederic Lecaille Date: Mon, 7 Sep 2026 14:12:41 +0000 (+0200) Subject: MEDIUM: quic: implement the AEAD confidentiality limit X-Git-Tag: v3.5-dev10~172 X-Git-Url: https://git.kaiwu.me/?a=commitdiff_plain;h=9a5915dfe545dfa57a0987b1756b1d0635870ac1;p=haproxy.git MEDIUM: quic: implement the AEAD confidentiality limit RFC 9001 section 6.6 limits the number of packets which may be encrypted with the same keys and requires a key update before this budget is exhausted. HAProxy did not enforce this limit or initiate local key updates. Count successful packet encryptions in a new member of quic_tls_secrets. Check the 1-RTT TX count from qc_send() and attempt a key update from seven eighths of the confidentiality limit, subject to the handshake confirmation and acknowledgment requirements of RFC 9001 section 6.1. This leaves some budget to keep sending while these requirements are not yet satisfied. Also check the remaining budget from qc_may_build_pkt(): a single call to qc_send() may encrypt several packets, so checking only on entry cannot prevent the limit from being exceeded. Reserve the last allowed encryption for CONNECTION_CLOSE. If no update is possible before this reserve is reached, close with AEAD_LIMIT_REACHED while the current keys can still protect the closing packet. quic_tls_initiate_key_update() is the local counterpart of a peer- initiated update. Reset the TX count and record the first packet number of the new phase when rotating keys. Track locally initiated updates with QUIC_FL_TLS_KP_LOCAL_IN_PROGRESS so that packets still sent by the peer with the previous keys can be decrypted without triggering another rotation. Clear this flag when a packet authenticates with the current keys. For AES-GCM, the limit is 2^23 packets per key, approximately 10 GB for 1200-byte packets. Long-lived connections can therefore reach it. ChaCha20-Poly1305 has a confidentiality limit greater than the QUIC packet number space and does not require this check. Validated with haterm and haload over HTTP/3 using AES-128-GCM. A single-connection transfer triggered a local update naturally at 7,340,032 encrypted packets, without counter injection. The TX count was reset to zero and the transfer completed successfully. Injected boundary scenarios also verified that CONNECTION_CLOSE consumes the reserved packet without exceeding the limit. This introduces locally initiated key updates for the first time. Keep it in 3.5 for interoperability exposure before considering a backport. --- diff --git a/include/haproxy/quic_tls-t.h b/include/haproxy/quic_tls-t.h index 365f8f6d4..6bcc75b1d 100644 --- a/include/haproxy/quic_tls-t.h +++ b/include/haproxy/quic_tls-t.h @@ -81,6 +81,11 @@ /* The ciphersuites for AEAD QUIC-TLS have 16-bytes authentication tags */ #define QUIC_TLS_TAG_LEN 16 /* bytes */ +/* Returned by quic_aead_confidentiality_limit() for an AEAD whose limit is + * greater than the number of possible packets, and may be disregarded. + */ +#define QUIC_AEAD_NO_CONFIDENTIALITY_LIMIT UINT64_MAX + /* The TLS extensions for QUIC transport parameters */ #define TLS_EXTENSION_QUIC_TRANSPORT_PARAMETERS 0x0039 #define TLS_EXTENSION_QUIC_TRANSPORT_PARAMETERS_DRAFT 0xffa5 @@ -203,8 +208,12 @@ struct quic_tls_kp { unsigned char flags; }; -/* Key update phase bit */ +/* Key phase bit of the packets we send */ #define QUIC_FL_TLS_KP_BIT_SET (1 << 0) +/* A key phase update initiated locally is in progress: the peer has not + * switched to the new key phase yet. + */ +#define QUIC_FL_TLS_KP_LOCAL_IN_PROGRESS (1 << 1) struct quic_tls_secrets { QUIC_AEAD_CTX *ctx; @@ -224,8 +233,16 @@ struct quic_tls_secrets { size_t ivlen; unsigned char *key; size_t keylen; - /* Used only on the RX part to store the largest received packet number */ + /* Frontier between the current and the previous key phase. On RX, the + * lowest packet number received with the current keys: a packet with a + * lower number was protected with the previous ones. On TX, the lowest + * packet number sent with the current keys, compared against the largest + * acknowledged one to know whether a new key update may be initiated + * (RFC 9001 6.1). + */ int64_t pn; + /* Used only on the TX part to count the packets encrypted with these keys */ + uint64_t count; }; struct quic_tls_ctx { diff --git a/include/haproxy/quic_tls.h b/include/haproxy/quic_tls.h index e33437cca..b128b5ea7 100644 --- a/include/haproxy/quic_tls.h +++ b/include/haproxy/quic_tls.h @@ -139,6 +139,7 @@ int quic_tls_hp_encrypt(unsigned char *out, int quic_tls_key_update(struct quic_conn *qc); void quic_tls_rotate_keys(struct quic_conn *qc); +int quic_tls_initiate_key_update(struct quic_conn *qc); static inline const QUIC_AEAD *tls_aead(const SSL_CIPHER *cipher) { @@ -220,6 +221,42 @@ static inline uint64_t quic_aead_integrity_limit(const QUIC_AEAD *aead) return 0; } +/* Return the number of packets which may be encrypted with the same keys with + * as AEAD algorithm, as stated by RFC 9001 6.6. + */ +static inline uint64_t quic_aead_confidentiality_limit(const QUIC_AEAD *aead) +{ + /* See quic_aead_integrity_limit() about QUIC_AEAD_API and the + * algorithms tls_aead() may return. + * + * The limit for ChaCha20-Poly1305 is greater than the number of + * possible packets, so it may be disregarded. + */ +#ifdef QUIC_AEAD_API + if (aead == EVP_aead_chacha20_poly1305()) + return QUIC_AEAD_NO_CONFIDENTIALITY_LIMIT; + if (aead == EVP_aead_aes_128_gcm() || + aead == EVP_aead_aes_256_gcm()) + return 1ULL << 23; +#else /* !QUIC_AEAD_API */ + switch (EVP_CIPHER_nid(aead)) { +#if !defined(USE_OPENSSL_WOLFSSL) + case NID_aes_128_ccm: + /* 2^21.5 */ + return 2965820; +#endif +#if (!defined(LIBRESSL_VERSION_NUMBER) || LIBRESSL_VERSION_NUMBER >= 0x4000000fL) + case NID_chacha20_poly1305: + return QUIC_AEAD_NO_CONFIDENTIALITY_LIMIT; +#endif + case NID_aes_128_gcm: + case NID_aes_256_gcm: + return 1ULL << 23; + } +#endif + return 0; +} + static inline const EVP_MD *tls_md(const SSL_CIPHER *cipher) { switch (SSL_CIPHER_get_id(cipher)) { @@ -791,6 +828,8 @@ static inline void quic_tls_ctx_reset(struct quic_tls_ctx *ctx) ctx->rx.key = NULL; ctx->rx.keylen = 0; ctx->rx.pn = 0; + /* Not used on the RX path. */ + ctx->rx.count = 0; ctx->tx.ctx = NULL; ctx->tx.aead = NULL; @@ -803,8 +842,8 @@ static inline void quic_tls_ctx_reset(struct quic_tls_ctx *ctx) ctx->tx.ivlen = 0; ctx->tx.key = NULL; ctx->tx.keylen = 0; - /* Not used on the TX path. */ ctx->tx.pn = 0; + ctx->tx.count = 0; ctx->flags = 0; } diff --git a/src/quic_rx.c b/src/quic_rx.c index d6acb229e..5fcb213e3 100644 --- a/src/quic_rx.c +++ b/src/quic_rx.c @@ -126,7 +126,7 @@ static int qc_do_rm_hp(struct quic_conn *qc, static int qc_pkt_decrypt(struct quic_conn *qc, struct quic_enc_level *qel, struct quic_rx_packet *pkt) { - int ret, kp_changed; + int ret, kp_changed, kp_prv; unsigned char iv[QUIC_TLS_IV_LEN]; struct quic_tls_ctx *tls_ctx = qc_select_tls_ctx(qc, qel, pkt->type, pkt->version); @@ -139,13 +139,15 @@ static int qc_pkt_decrypt(struct quic_conn *qc, struct quic_enc_level *qel, ret = 0; kp_changed = 0; + kp_prv = 0; if (pkt->type == QUIC_PACKET_TYPE_SHORT) { /* The two tested bits are not at the same position, * this is why they are first both inversed. */ if (!(*pkt->data & QUIC_PACKET_KEY_PHASE_BIT) ^ !(tls_ctx->flags & QUIC_FL_TLS_KP_BIT_SET)) { - if (pkt->pn < tls_ctx->rx.pn) { + if ((tls_ctx->flags & QUIC_FL_TLS_KP_LOCAL_IN_PROGRESS) || + pkt->pn < tls_ctx->rx.pn) { /* The previous key phase secrets are only * available after a first key update. */ @@ -157,6 +159,7 @@ static int qc_pkt_decrypt(struct quic_conn *qc, struct quic_enc_level *qel, rx_ctx = qc->ku.prv_rx.ctx; rx_iv = qc->ku.prv_rx.iv; rx_key = qc->ku.prv_rx.key; + kp_prv = 1; } else if (pkt->pn > qel->pktns->rx.largest_pn) { /* Next key phase */ @@ -217,6 +220,11 @@ static int qc_pkt_decrypt(struct quic_conn *qc, struct quic_enc_level *qel, if (!quic_tls_key_update(qc)) TRACE_ERROR("quic_tls_key_update() failed", QUIC_EV_CONN_RXPKT, qc); } + else if (!kp_prv && (tls_ctx->flags & QUIC_FL_TLS_KP_LOCAL_IN_PROGRESS)) { + /* The peer has switched to the new key phase. */ + tls_ctx->flags &= ~QUIC_FL_TLS_KP_LOCAL_IN_PROGRESS; + tls_ctx->rx.pn = pkt->pn; + } /* Update the packet length (required to parse the frames). */ pkt->len -= QUIC_TLS_TAG_LEN; diff --git a/src/quic_tls.c b/src/quic_tls.c index 0c373eefd..5d32d8cfa 100644 --- a/src/quic_tls.c +++ b/src/quic_tls.c @@ -1189,9 +1189,52 @@ void quic_tls_rotate_keys(struct quic_conn *qc) qc->ku.nxt_tx.iv = curr_iv; qc->ku.nxt_tx.key = curr_key; + /* The new keys have not encrypted any packet yet. */ + tls_ctx->tx.count = 0; + /* Store the lowest packet number to be sent with the new keys. */ + tls_ctx->tx.pn = qc->ael->pktns->tx.next_pn + 1; + TRACE_LEAVE(QUIC_EV_CONN_RXPKT, qc); } +/* Initiate a key update for connection. This is the local counterpart of + * the key update triggered by the peer. + * Return 1 if succeeded, 0 if not. + */ +int quic_tls_initiate_key_update(struct quic_conn *qc) +{ + struct quic_tls_ctx *tls_ctx = &qc->ael->tls_ctx; + int ret = 0; + + TRACE_ENTER(QUIC_EV_CONN_KP, qc); + + /* The next keys may be missing if a previous update failed. */ + if ((!qc->ku.nxt_rx.ctx || !qc->ku.nxt_tx.ctx) && + !quic_tls_key_update(qc)) { + TRACE_ERROR("quic_tls_key_update() failed", QUIC_EV_CONN_KP, qc); + goto leave; + } + + quic_tls_rotate_keys(qc); + /* Toggle the Key Phase bit */ + tls_ctx->flags ^= QUIC_FL_TLS_KP_BIT_SET; + /* The peer keeps sending with the previous key phase for at least one + * round trip. Its packets must be decrypted with the previous keys. + */ + tls_ctx->flags |= QUIC_FL_TLS_KP_LOCAL_IN_PROGRESS; + + /* Prepare the next key update. On failure, the keys are derived again + * at the next key phase change. + */ + if (!quic_tls_key_update(qc)) + TRACE_ERROR("quic_tls_key_update() failed", QUIC_EV_CONN_KP, qc); + + ret = 1; + leave: + TRACE_LEAVE(QUIC_EV_CONN_KP, qc); + return ret; +} + /* Release the memory allocated for the QUIC TLS context with as address. */ void quic_tls_ctx_free(struct quic_tls_ctx **ctx) { diff --git a/src/quic_tx.c b/src/quic_tx.c index eeb9d5e83..aa62f84cf 100644 --- a/src/quic_tx.c +++ b/src/quic_tx.c @@ -66,6 +66,9 @@ static void quic_packet_encrypt(unsigned char *payload, size_t payload_len, *fail = 1; enc_debug_info_init(&edi, payload, payload_len, aad, aad_len, pn); } + else { + tls_ctx->tx.count++; + } TRACE_LEAVE(QUIC_EV_CONN_ENCPKT, qc); } @@ -124,6 +127,34 @@ void qc_txb_release(struct quic_conn *qc) } } +/* Return 1 if a packet may be encrypted with the keys currently in use to + * protect , 0 if their confidentiality limit is reached. is set when + * this packet carries the CONNECTION_CLOSE frame. + * + * The number of packets encrypted with a given key must never exceed the + * confidentiality limit of its AEAD algorithm, the CONNECTION_CLOSE frame + * included. One packet of the budget is therefore reserved for that frame, so + * that the peer can always be notified before the keys are exhausted. + */ +static int quic_tls_tx_allowed(const struct quic_conn *qc, + const struct quic_enc_level *qel, int cc) +{ + uint64_t count, limit; + + if (qel != qc->ael) + return 1; + + if (!quic_tls_has_tx_sec(qel) || !qel->tls_ctx.tx.aead) + return 1; + + count = qel->tls_ctx.tx.count; + limit = quic_aead_confidentiality_limit(qel->tls_ctx.tx.aead); + if (limit == QUIC_AEAD_NO_CONFIDENTIALITY_LIMIT) + return 1; + + return cc ? count < limit : count + 1 < limit; +} + /* Return the TX buffer dedicated to the "connection close" datagram to be built * if an immediate close is required after having allocated it or directly * allocate a TX buffer if an immediate close is not required. @@ -216,11 +247,16 @@ static int qc_may_build_pkt(struct quic_conn *qc, struct list *frms, * if there is nothing to send, i.e. if no CONNECTION_CLOSE or ACK are required * and if there is no more packets to send upon PTO expiration * and if there is no more ack-eliciting frames to send or in flight - * congestion control limit is reached for prepared data + * congestion control limit is reached for prepared data. + * + * Also do not build any packet if the confidentiality limit of the keys + * in use is reached with no possible key update: no more packet may + * consume them, except the one carrying the CONNECTION_CLOSE frame. */ if (!quic_tls_has_tx_sec(qel) || (!cc && !probe && !*must_ack && - (LIST_ISEMPTY(frms) || qc->path->prep_in_flight >= qc->path->cwnd))) { + (LIST_ISEMPTY(frms) || qc->path->prep_in_flight >= qc->path->cwnd)) || + !quic_tls_tx_allowed(qc, qel, cc)) { return 0; } @@ -877,6 +913,81 @@ static int qc_prep_pkts(struct quic_conn *qc, struct buffer *buf, return -1; } +/* Check the AEAD confidentiality limit of RFC 9001 6.6 for connection. + * Initiate a key update before the limit is reached, and close the connection + * if it is reached without any possible key update. + * + * This must be called before preparing packets, to initiate a key update or + * arm an immediate close before selecting the TX buffer. As a single call may + * encrypt several packets, qc_may_build_pkt() also checks the remaining budget + * before building each packet. + */ +static void qc_check_aead_confidentiality_limit(struct quic_conn *qc) +{ + struct quic_tls_ctx *tls_ctx; + uint64_t count, limit; + int can_update; + + TRACE_ENTER(QUIC_EV_CONN_KP, qc); + + if (qc->flags & (QUIC_FL_CONN_IMMEDIATE_CLOSE|QUIC_FL_CONN_CLOSING| + QUIC_FL_CONN_DRAINING)) + goto leave; + + /* The 1-RTT encryption level may be allocated without usable secrets. */ + if (!quic_tls_has_tx_sec(qc->ael) || !qc->ael->tls_ctx.tx.aead) + goto leave; + + tls_ctx = &qc->ael->tls_ctx; + count = tls_ctx->tx.count; + limit = quic_aead_confidentiality_limit(tls_ctx->tx.aead); + if (!limit) { + TRACE_ERROR("unknown AEAD algorithm", QUIC_EV_CONN_KP, qc); + quic_set_connection_close(qc, quic_err_transport(QC_ERR_INTERNAL_ERROR)); + goto leave; + } + + /* RFC 9001 6.6: the limit of this AEAD is greater than the number of + * possible packets, so it may be disregarded. + */ + if (limit == QUIC_AEAD_NO_CONFIDENTIALITY_LIMIT) + goto leave; + + /* Start the key update before the limit is reached, to leave enough + * room for the acknowledgement required by the next one. + */ + if (count < limit - (limit >> 3)) + goto leave; + + /* RFC 9001 6.1. Initiating a Key Update + * + * An endpoint MUST NOT initiate a key update prior to having confirmed + * the handshake (Section 4.1.2). An endpoint MUST NOT initiate a + * subsequent key update unless it has received an acknowledgment for a + * packet that was sent protected with keys from the current key phase. + */ + can_update = count + 1 < limit && + qc->state >= QUIC_HS_ST_CONFIRMED && + !(tls_ctx->flags & QUIC_FL_TLS_KP_LOCAL_IN_PROGRESS) && + tls_ctx->tx.pn <= qc->ael->pktns->rx.largest_acked_pn; + if (can_update && quic_tls_initiate_key_update(qc)) + goto leave; + + /* No key update is possible, and the keys may not encrypt any more + * regular packet: no more than the confidentiality limit of the AEAD + * algorithm may be consumed, the CONNECTION_CLOSE frame included. Close + * the connection now, while the packet reserved for that frame is still + * available. + */ + if (!can_update && count + 1 >= limit) { + TRACE_ERROR("AEAD confidentiality limit reached", QUIC_EV_CONN_KP, qc); + quic_set_connection_close(qc, quic_err_transport(QC_ERR_AEAD_LIMIT_REACHED)); + } + + leave: + TRACE_LEAVE(QUIC_EV_CONN_KP, qc); +} + /* Encode frames and send them as packets for connection. Input frames are * specified via quic_enc_level through their send_frms member. Set * when reemitted duplicated data. @@ -900,6 +1011,13 @@ int qc_send(struct quic_conn *qc, int old_data, struct list *send_list, TRACE_ENTER(QUIC_EV_CONN_TXPKT, qc); + /* The AEAD confidentiality limit is checked here, before anything is + * prepared: an immediate close armed by that check is sent by the caller + * or the connection layer, from its own dedicated buffer, so that no + * further packet may be built with exhausted keys. + */ + qc_check_aead_confidentiality_limit(qc); + buf = qc_get_txb(qc); if (!buf) { TRACE_ERROR("buffer allocation failed", QUIC_EV_CONN_TXPKT, qc);