#3607·h2o

Several QUIC (RFC 9000 / RFC 9001 / RFC 9002) Compliance Issues in h2o

Author: Zhaodl1Created Jun 20, 2026Updated Sep 9, 2026

Hi maintainers, While reviewing h2o's QUIC implementation (quicly + picotls) against RFC 9000, RFC 9001, and RFC 9002, we noticed several places where the current behavior appears to differ from the specification. Each item below cites the relevant RFC text alongside the corresponding source code for your reference. We hope this is helpful for improving RFC conformance.


1. Persistent Congestion Handler Not Implemented — cwnd Not Reduced to kMinimumWindow

RFC Reference: RFC 9002 Section 7.6.2

"When persistent congestion is declared, the sender's congestion window MUST be reduced to the minimum congestion window (kMinimumWindow)."

Analysis: All three congestion-control implementations register a cc_on_persistent_congestion callback that consists solely of a /* TODO */ comment and performs no cwnd reduction. Additionally, a search across lib/quicly.c and lib/loss.c shows that the persistent_congestion symbol appears only in the CC type registrations — the callback is never invoked from any loss-detection or ACK-handling path. Consequently, even if a persistent-congestion event were detected, no code path would trigger the cwnd reduction required by RFC 9002 §7.6.2.

Source Code Evidence (deps/quicly/lib/cc-reno.c):

c
// deps/quicly/lib/cc-reno.c:92-95
void quicly_cc_reno_on_persistent_congestion(quicly_cc_t *cc, const quicly_loss_t *loss, int64_t now)
{
    /* TODO */
}
c
// deps/quicly/lib/cc-cubic.c:155-158
static void cubic_on_persistent_congestion(quicly_cc_t *cc, const quicly_loss_t *loss, int64_t now)
{
    /* TODO */
}
c
// deps/quicly/lib/cc-pico.c:231-234
static void pico_on_persistent_congestion(quicly_cc_t *cc, const quicly_loss_t *loss, int64_t now)
{
    /* TODO */
}

2. NewSessionTicket early_data max_early_data_size Not Validated Against 0xffffffff Sentinel

RFC Reference: RFC 9001 Section 4.6.1

"A client MUST treat receipt of a NewSessionTicket that contains an early_data extension with any other value [than 0xffffffff] as a connection error of type PROTOCOL_VIOLATION."

Analysis: In decode_new_session_ticket, the max_early_data_size field of the early_data extension is decoded via ptls_decode32 without any comparison against the 0xffffffff sentinel. The client-side handler client_handle_new_session_ticket likewise performs no sentinel check before saving the ticket. A server issuing a NST with a non-sentinel max_early_data_size (e.g., a finite byte limit) would be silently accepted by the client, rather than triggering the PROTOCOL_VIOLATION connection error specified by RFC 9001 §4.6.1.

Source Code Evidence (deps/picotls/lib/picotls.c):

c
// deps/picotls/lib/picotls.c:1540-1549
    *max_early_data_size = 0;
    decode_extensions(src, end, PTLS_HANDSHAKE_TYPE_NEW_SESSION_TICKET, &exttype, {
        ...
        case PTLS_EXTENSION_TYPE_EARLY_DATA:
            if ((ret = ptls_decode32(max_early_data_size, &src, end)) != 0)
                goto Exit;
            break;
        // no comparison against 0xffffffff / UINT32_MAX

3. TLS KeyUpdate with update_requested=0 Silently Accepted Rather Than Treated as Connection Error

RFC Reference: RFC 9001 Section 6

"Endpoints MUST NOT send a TLS KeyUpdate message. Endpoints MUST treat the receipt of a TLS KeyUpdate message as a connection error of type 0x010a."

Analysis: handle_key_update first unconditionally calls update_traffic_key(tls, 0) to rotate the receive key, then branches on the update_requested byte. When update_requested == 1 and a QUIC update-traffic-key callback is installed, the function returns PTLS_ALERT_UNEXPECTED_MESSAGE (which maps to QUIC error 0x010a, compliant with the RFC). However, when update_requested == 0, the function returns 0 (success) after having already derived and installed new receive keys. This silently accepts the forbidden TLS KeyUpdate message rather than treating its receipt as a connection error, inconsistent with RFC 9001 §6's unconditional "MUST treat the receipt ... as a connection error" requirement.

Source Code Evidence (deps/picotls/lib/picotls.c):

c
// deps/picotls/lib/picotls.c:5060-5080
static int handle_key_update(ptls_t *tls, ptls_message_emitter_t *emitter, ptls_iovec_t message)
{
    const uint8_t *src = message.base + PTLS_HANDSHAKE_HEADER_SIZE, *const end = message.base + message.len;
    int ret;

    if (end - src != 1 || *src > 1)
        return PTLS_ALERT_DECODE_ERROR;

    /* update receive key */
    if ((ret = update_traffic_key(tls, 0)) != 0)   // receive key rotated unconditionally
        return ret;

    if (*src) {                                     // update_requested == 1
        if (tls->ctx->update_traffic_key != NULL)
            return PTLS_ALERT_UNEXPECTED_MESSAGE;
        tls->needs_key_update = 1;
    }

    return 0;                                       // update_requested == 0: silently accepted
}

4. Non-empty legacy_session_id Triggers Middlebox Compatibility Mode Rather Than PROTOCOL_VIOLATION

RFC Reference: RFC 9001 Section 8.4

"A server SHOULD treat the receipt of a TLS ClientHello with a non-empty legacy_session_id field as a connection error of type PROTOCOL_VIOLATION."

Analysis: In server_handle_hello, a non-empty legacy_session_id causes the server to set tls->send_change_cipher_spec = 1, enabling the TLS 1.2 middlebox compatibility mode (sending change_cipher_specs records). The constraints checker check_client_hello_constraints does not mention legacy_session_id at all. The current behavior is the opposite of what RFC 9001 §8.4 recommends: rather than aborting the handshake with PROTOCOL_VIOLATION, the server accommodates the middlebox-compat signal. (This is a SHOULD, not a MUST.)

Source Code Evidence (deps/picotls/lib/picotls.c):

c
// deps/picotls/lib/picotls.c:4509-4510
    if (!is_second_flight) {
        if (ch->legacy_session_id.len != 0)
            tls->send_change_cipher_spec = 1;   // middlebox compat enabled, not rejected

5. Post-Handshake CertificateRequest Mapped to unexpected_message (0x010a) Rather Than PROTOCOL_VIOLATION (0x0a)

RFC Reference: RFC 9001 Section 4.4

"Servers MUST NOT send post-handshake TLS CertificateRequest messages, and clients MUST treat receipt of such messages as a connection error of type PROTOCOL_VIOLATION."

Analysis: When a post-handshake CertificateRequest is received, the client's post-handshake state machine routes it to the default branch of the message-type switch, returning PTLS_ALERT_UNEXPECTED_MESSAGE (TLS alert 10). quicly's transport-error mapping (QUICLY_TRANSPORT_ERROR_CRYPTO(tls_alert) = 0x100 + (tls_alert)) converts this to QUIC error code 0x010a. The RFC, however, specifies the error type as PROTOCOL_VIOLATION (QUIC code 0x0a). The connection is closed, but the error code differs from the one the RFC designates for this case. (The server side does not send post-handshake CertificateRequest, so the "MUST NOT send" portion is satisfied.)

Source Code Evidence (deps/picotls/lib/picotls.c):

c
// deps/picotls/lib/picotls.c:5719-5730
    case PTLS_STATE_CLIENT_POST_HANDSHAKE:
        switch (type) {
        case PTLS_HANDSHAKE_TYPE_NEW_SESSION_TICKET:
            ret = client_handle_new_session_ticket(tls, message);
            break;
        case PTLS_HANDSHAKE_TYPE_KEY_UPDATE:
            ret = handle_key_update(tls, emitter, message);
            break;
        default:
            ret = PTLS_ALERT_UNEXPECTED_MESSAGE;   // alert 10 -> QUIC 0x010a, not 0x0a
            break;
        }
c
// deps/quicly/include/quicly/constants.h:103,109
#define QUICLY_TRANSPORT_ERROR_PROTOCOL_VIOLATION QUICLY_ERROR_FROM_TRANSPORT_ERROR_CODE(0xa)
#define QUICLY_TRANSPORT_ERROR_CRYPTO(tls_alert)  QUICLY_ERROR_FROM_TRANSPORT_ERROR_CODE(0x100 + (tls_alert))

6. ECN Validation Lacks total_acked vs. sum(ecn_counts) Comparison

RFC Reference: RFC 9000 Section 13.4.2.1 (referenced by RFC 9002)

"If validation fails, then the endpoint MUST disable ECN. It stops setting the ECT codepoint in IP packets that it sends."

Analysis: The ACK-frame handler validates ECN by turning ECN off when a non-zero ECT(1) count is observed, and by promoting the state from QUICLY_ECN_PROBING to QUICLY_ECN_ON when expected marks are acknowledged. However, the additional validation step — comparing the total number of newly-acknowledged packets against the sum of the ECN counters to detect a middlebox that strips ECN marks (receiving packets as NON-ECT) — is explicitly marked as a TODO and not implemented. The ECT(1) check and the probing timeout fallback at quicly.c:5573-5574 partially mitigate this gap, but the NON-ECT-remarking detection path required by RFC 9000 §13.4.2.1 is currently absent.

Source Code Evidence (deps/quicly/lib/quicly.c):

c
// deps/quicly/lib/quicly.c:6426-6434
    if (conn->egress.ecn.state != QUICLY_ECN_OFF && largest_newly_acked.pn != UINT64_MAX) {
        /* if things look suspicious (ECT(1) count becoming non-zero), turn ECN off */
        if (frame.ecn_counts[1] != 0)
            update_ecn_state(conn, QUICLY_ECN_OFF);
        /* TODO: maybe compare num_packets.acked vs. sum(ecn_counts) to see if any packet has been received as NON-ECT? */

        /* ECN validation succeeds if at least one packet is acked using one of the expected marks during the probing period */
        if (conn->egress.ecn.state == QUICLY_ECN_PROBING && frame.ecn_counts[0] + frame.ecn_counts[2] > 0)
            update_ecn_state(conn, QUICLY_ECN_ON);

7. Single Global PTO Timer May Arm for Application Data Space Before Handshake Confirmation

RFC Reference: RFC 9002 Section 6.2.1

"An endpoint MUST NOT set its PTO timer for the Application Data packet number space until the handshake is confirmed."

Analysis: update_send_alarm derives handshake_is_in_progress from whether the Initial or Handshake cipher spaces still exist (conn->initial != NULL || conn->handshake != NULL), not from explicit handshake confirmation. quicly_loss_update_alarm operates on a single global PTO timer driven by last_retransmittable_sent_at, which is updated whenever any retransmittable packet — including 1-RTT Application Data — is sent. On the client side, 1-RTT data can be sent after receiving the server's Finished but before HANDSHAKE_DONE is received (i.e., before the client considers the handshake confirmed). In that window, sending 1-RTT data updates last_retransmittable_sent_at and arms the global PTO timer, which effectively covers the Application Data packet number space, inconsistent with RFC 9002 §6.2.1.

Source Code Evidence (deps/quicly/lib/quicly.c):

c
// deps/quicly/lib/quicly.c:1553-1558
static void update_send_alarm(quicly_conn_t *conn, int can_send_stream_data, int is_after_send)
{
    int handshake_is_in_progress = conn->initial != NULL || conn->handshake != NULL;
    quicly_loss_update_alarm(&conn->egress.loss, conn->stash.now, conn->egress.last_retransmittable_sent_at, has_outstanding,
                             can_send_stream_data, handshake_is_in_progress, conn->egress.max_data.sent, is_after_send);
c
// deps/quicly/include/quicly/loss.h:345
    SET_ALARM(last_retransmittable_sent_at + alarm_duration);   // single timer, shared across all epochs

8. Client Does Not Explicitly Discard Subsequent Initial Packets with Mismatched Source CID

RFC Reference: RFC 9000 Section 7.2

"Once a client has received a valid Initial packet from the server, it MUST discard any subsequent packet it receives on that connection with a different Source Connection ID."

Analysis: In the Initial-packet receive path, the client updates its stored server CID only while in the QUICLY_STATE_FIRSTFLIGHT state. After transitioning to QUICLY_STATE_CONNECTED, there is no per-packet comparison of the incoming Initial's Source CID against the stored value, and no explicit discard for mismatches. A subsequent Initial packet carrying a different Source CID would be processed up to the point of Initial-key availability. The practical impact is bounded because Initial keys are typically discarded once the Handshake flight is received (conn->initial == NULL causes such packets to be ignored at quicly.c:7528-7530), but the explicit per-packet SCID check required by RFC 9000 §7.2 is not present in the interim window.

Source Code Evidence (deps/quicly/lib/quicly.c):

c
// deps/quicly/lib/quicly.c:7532-7542
        case QUICLY_PACKET_TYPE_INITIAL:
            if (conn->initial == NULL || (header_protection = conn->initial->cipher.ingress.header_protection) == NULL) {
                ret = QUICLY_ERROR_PACKET_IGNORED;
                goto Exit;
            }
            if (quicly_is_client(conn)) {
                /* client: update cid if this is the first Initial packet that's being received */
                if (conn->super.state == QUICLY_STATE_FIRSTFLIGHT)
                    quicly_set_cid(&conn->super.remote.cid_set.cids[0].cid, packet->cid.src);
                // no else-branch comparing packet->cid.src against stored CID for subsequent Initials

9. Key-Update Initiation Guarded by "1-RTT ACK Received" Rather Than Explicit Handshake Confirmation (Client Side)

RFC Reference: RFC 9001 Section 6.1

"An endpoint MUST NOT initiate a key update prior to having confirmed the handshake."

Analysis: Key-update initiation is gated by key_update_pn.next, which is set to packet_number + max_packets_per_key only after the first 1-RTT packet is acknowledged (at quicly.c:6368-6370). On the server side, a 1-RTT ACK can only occur after the client's Finished is verified, so this is equivalent to handshake completion and the guard is compliant. On the client side, however, the server may ACK 1-RTT data before sending HANDSHAKE_DONE; in that case key_update_pn.next becomes finite before the client's handshake-confirmed condition (receipt of HANDSHAKE_DONE) is satisfied, allowing update_1rtt_egress_key to be initiated earlier than RFC 9001 §6.1 permits from the client's perspective.

Source Code Evidence (deps/quicly/lib/quicly.c):

c
// deps/quicly/lib/quicly.c:1845-1847
    /* prohibit key-update until receiving an ACK for an 1-RTT packet */
    conn->application->cipher.egress.key_update_pn.last = 0;
    conn->application->cipher.egress.key_update_pn.next = UINT64_MAX;
c
// deps/quicly/lib/quicly.c:6368-6370
                if (space->cipher.egress.key_update_pn.last <= pn_acked) {
                    space->cipher.egress.key_update_pn.last = UINT64_MAX;
                    space->cipher.egress.key_update_pn.next = conn->egress.packet_number + conn->super.ctx->max_packets_per_key;
c
// deps/quicly/lib/quicly.c:3928-3931
        if (conn->egress.packet_number >= conn->application->cipher.egress.key_update_pn.next) {
            int ret;
            if ((ret = update_1rtt_egress_key(conn)) != 0)
                return ret;