Skip to content

Retired connection ID token remains valid until ACK #3260

Description

@LiD0209

Retired connection ID token remains valid until ACK

Summary

After s2n-quic sends RETIRE_CONNECTION_ID, it marks the retired peer CID as PendingAcknowledgement, but the corresponding stateless reset token remains in stateless_reset_map until the retire frame is ACKed. RFC 9000 requires the token to become invalid when the CID is retired via RETIRE_CONNECTION_ID, and it forbids checking tokens for retired CIDs. This is a real inconsistency.

Standard Requirement

RFC 9000 says stateless reset tokens are invalidated when their associated connection ID is retired via a RETIRE_CONNECTION_ID frame. It also says an endpoint must not check stateless reset tokens associated with connection IDs it has not used or connection IDs that have been retired.

The key point is that the token becomes invalid when the CID is retired via RETIRE_CONNECTION_ID, not when that frame is later ACKed.

Relevant Source Code

implementions/s2n-quic/quic/s2n-quic-transport/src/connection/peer_id_registry.rs:455-466

if let Some(packet_number) = context.write_frame(&frame::RetireConnectionId {
    sequence_number: id_info.sequence_number.into(),
}) {
    id_info.status = PendingAcknowledgement(packet_number);
    self.transmission_interest.clear();
    self.ack_interest.clear();
}

After sending RETIRE_CONNECTION_ID, the implementation enters PendingAcknowledgement but does not remove the token.

implementions/s2n-quic/quic/s2n-quic-transport/src/connection/peer_id_registry.rs:483-505

if let PendingAcknowledgement(packet_number) = id_info.status {
    if ack_set.contains(packet_number) {
        if let Some(token) = id_info.stateless_reset_token {
            mapper_state.stateless_reset_map.remove(&token);
        }

        self.ack_interest.clear();
        return false;
    }
}

Token removal happens only during ACK handling.

implementions/s2n-quic/quic/s2n-quic-transport/src/endpoint/mod.rs:897-916

fn close_on_matching_stateless_reset(
    &mut self,
    payload: &[u8],
    timestamp: Timestamp,
) -> Option<InternalConnectionId> {
    let token_index = payload.len().checked_sub(StatelessResetTokenLen)?;
    let buffer = buffer.skip(token_index).ok()?;
    let (token, _) = buffer.decode().ok()?;
    let internal_id = self
        .connection_id_mapper
        .remove_internal_connection_id_by_stateless_reset_token(&token)?;

As long as the retired token remains in the mapper, an endpoint datagram ending in that token can still enter Stateless Reset handling.

Implementation Behavior

After receiving NEW_CONNECTION_ID with a larger Retire Prior To, the old peer CID is marked PendingRetirement. After sending RETIRE_CONNECTION_ID, it becomes PendingAcknowledgement; the old token still matches in stateless_reset_map. Only after ACK arrives does on_packet_ack remove the token and delete the CID.

Inconsistency Reason

RFC 9000 requires a retired CID's token to be invalid and no longer checked. The implementation delays invalidation until ACK, so during the interval after RETIRE_CONNECTION_ID is sent and before it is ACKed, the retired CID's token is still treated as a valid stateless reset token.

Runtime Evidence

I ran the existing test:

cargo test -p s2n-quic-transport connection::peer_id_registry::tests::retire_connection_id_when_retire_prior_to_increases -- --exact --nocapture

It passed with exit code 0. The test asserts that before ACK, the old token still maps to the internal connection ID, and after ACK, the token no longer maps:

reg.on_transmit(&mut write_context);

assert_eq!(
    Some(reg.internal_id),
    mapper.remove_internal_connection_id_by_stateless_reset_token(&TEST_TOKEN_1)
);

reg.on_packet_ack(&PacketNumberRange::new(packet_number, packet_number));

assert_eq!(
    None,
    mapper.remove_internal_connection_id_by_stateless_reset_token(&TEST_TOKEN_1)
);

I also ran a temporary negative reproducer that expected the old token to be invalid immediately after reg.on_transmit(&mut write_context). That reproducer failed with exit code 101 and observed:

assertion `left == right` failed
  left: None
 right: Some(InternalConnectionId(0))

The temporary test was removed after the run.

Impact

While ACK is delayed or lost, a stateless reset token for a retired CID can still trigger Stateless Reset detection. That violates RFC 9000's token invalidation and no-checking rules for retired CIDs.

Fix Direction

Remove the corresponding token from stateless_reset_map immediately when RETIRE_CONNECTION_ID is sent and the CID is retired. Keep enough retirement state to track ACK and retransmit the retire frame if needed.

Merged Redundant Reports

The following reports described the same root cause and fix direction, so they were merged into this report and removed:

  • 0233-static unresolved requirement.md: retired CID stateless reset token remains active while RETIRE_CONNECTION_ID is pending ACK.
  • 0238-static unresolved requirement.md: same pending-ACK retired-CID token lifetime issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions