cargo / quinn-proto / audit
cargo : quinn-proto @ 0.11.14
PE Patrick Elsen signed 2026-05-28 published 2026-05-28

Claims

concurrency-documentedconcurrency-safecrypto-safehas-binarieshas-build-exechas-fuzz-testshas-install-exechas-integration-testshas-property-testshas-unit-testsimpl-algorithmimpl-concurrencyimpl-cryptoimpl-datastructureimpl-interpreterimpl-jitimpl-parserimpl-protocolis-benignparser-impl-safeparser-impl-testedprotocol-impl-safeprotocol-impl-testedunsafe-documentedunsafe-minimalunsafe-safeunsafe-testeduses-concurrencyuses-cryptouses-environmentuses-execuses-filesystemuses-interpreteruses-jituses-networkuses-unsafe

Summary

quinn-proto 0.11.14 is the sans-I/O QUIC protocol state machine (RFC 9000). The codebase contains exactly five unsafe blocks, all calling VarInt::from_u64_unchecked with structurally bounded values; the invariants hold but lack SAFETY comments (one low-severity finding). The anti-amplification limit, retry/validation token handling, frame parser, and stream-count enforcement were reviewed and are correct. Crypto is delegated to rustls. Four fuzz targets cover the untrusted-input surface.

Report

Subject

quinn-proto 0.11.14 is the sans-I/O state machine for the QUIC transport protocol (RFC 9000). It implements packet parsing and encoding, the connection state machine (handshake through close), stream multiplexing with flow control, congestion control (New Reno and BBR), path migration, MTU discovery, retry and validation token handling, and QUIC datagram frames. Cryptographic operations are fully delegated to pluggable backends: rustls (backed by ring or aws-lc-rs), with raw interfaces (crypto::PacketKey, crypto::HeaderKey, crypto::HandshakeTokenKey) available for alternative implementations. The crate has no I/O; callers supply timestamps, network events, and buffers and receive outgoing datagrams and application events in return. It targets RFC 9000 (QUIC) and RFC 9001 (QUIC-TLS).

Methodology

The published crate contents were compared against the upstream Git repository at the commit recorded in .cargo_vcs_info.json using diff -rq. All unsafe blocks were located with grep -rEn '\bunsafe\s*(\{|fn|impl|trait)'. The following source files were read in full: src/lib.rs, src/varint.rs, src/packet.rs, src/frame.rs, src/token.rs, src/constant_time.rs, src/endpoint.rs (first 600 lines), src/connection/paths.rs (first 200 lines), src/connection/send_buffer.rs, and src/connection/streams/state.rs (stream limit sections). Additional sections of src/connection/mod.rs were read around the anti-amplification logic (lines 580-590, 1110-1140, 3250-3270). The frame parser (frame::Iter and scan_ack_blocks) was read in full. The fuzz target directory in the VCS root was confirmed. The dependency list from Cargo.toml was reviewed against the extracted dependencies.json. Source surveys were run for network access, filesystem access, process execution, environment variable access, FFI, and concurrency patterns.

Results

The diff -rq comparison shows only the expected Cargo.toml normalisation difference; all source files are byte-for-byte identical between the published crate and VCS. No binary artifacts are present, justifying has-binaries. There is no build.rs and the library is not a proc macro, so has-build-exec and has-install-exec are false. The crate ships no install hooks, so has-install-exec is false. 250 #[test] annotations were found in src/, justifying has-unit-tests. The crate has no integration test directory, so has-integration-tests is false, and no property tests were found, so has-property-tests is false. Fuzz targets (packet.rs, params.rs, streamid.rs, streams.rs) exist in the VCS root at fuzz/fuzz_targets/, justifying has-fuzz-tests.

The codebase contains exactly five unsafe blocks, all calling VarInt::from_u64_unchecked. The VarInt type requires its inner value to be less than 2^62. The call sites are: converting a StreamId to VarInt (invariant holds because stream IDs are limited to MAX_STREAM_COUNT = 1 << 60); two calls in send_buffer.rs sizing a retransmit offset and the unsent pointer (stream offsets are flow-controlled and bounded well within the VarInt range); and one in connection/mod.rs for a CRYPTO frame offset (bounded by TLS record sizes). The invariants are sound but none of the call sites carries a // SAFETY: comment, producing one low-severity finding (FINDING-1). The unsafe is confined to a single function and represents minimal surface. This justifies uses-unsafe, unsafe-safe, unsafe-minimal; the absence of safety comments justifies unsafe-documented. The crate does not submit unsafe blocks to Miri or sanitizers as part of its CI, so unsafe-tested is false.

The anti-amplification limit is implemented in PathData::anti_amplification_blocked, which returns true when the path is not validated and total_recvd * 3 < total_sent + bytes_to_send. The check is applied before each batch of outgoing datagrams. total_recvd is updated with saturating_add after each incoming datagram. This correctly enforces RFC 9000 §8.1. The crate implements the QUIC protocol (impl-protocol) and does not implement cryptography (impl-crypto), interpreters (impl-interpreter), JIT (impl-jit), standalone data structures (impl-datastructure), or standalone algorithms (impl-algorithm). Concurrency primitives are not implemented; the Arc usage is for shared configuration only (impl-concurrency).

Retry and validation tokens are AEAD-encrypted with per-token HkDF-derived keys and include an expiry check and source-address binding; replay prevention is delegated to the pluggable TokenLog interface (with a bloom-filter implementation available as BloomTokenLog). This justifies uses-crypto and crypto-safe. The crate uses no JIT (uses-jit) or interpreter (uses-interpreter).

The frame parser (frame::Iter) validates all length fields before slicing. ACK block decoding uses checked_sub throughout scan_ack_blocks to prevent underflow on malformed input. Empty payloads are rejected. Stream limit enforcement (validate_receive_id) correctly applies max_remote bounds and returns STREAM_LIMIT_ERROR for out-of-bounds peer stream IDs. The frame parser and packet header decoder implement a parser (impl-parser), justifying parser-impl-safe and parser-impl-tested. The packet header encoding test in src/packet.rs validates round-trip against a known byte sequence, providing a reference vector check. Spec conformance against the full RFC 9000 test suite was not evaluated in this audit, so parser-impl-correct and protocol-impl-correct were not asserted. The protocol implementation is covered by 250 unit tests and four fuzz targets (protocol-impl-tested).

The codebase uses no network sockets (uses-network), no filesystem operations (uses-filesystem), no process execution (uses-exec), and no environment variables (uses-environment). The Arc usage for shared configuration objects qualifies as concurrency usage (uses-concurrency); Endpoint and Connection use &mut self APIs. The API design inherently documents the single-driver concurrency contract, justifying concurrency-safe and concurrency-documented. No obfuscated code, base64 blobs, or suspicious network endpoints were found, justifying is-benign.

One low-severity finding was identified (FINDING-1): the five unsafe blocks lack // SAFETY: comments. No correctness, security, or other safety findings were identified.

Conclusion

The audit found one low-severity quality finding (FINDING-1): missing // SAFETY: comments on all five unsafe call sites. The unsafe blocks themselves are sound: all five call VarInt::from_u64_unchecked with values that are structurally bounded well within the 2^62 limit. The anti-amplification logic, retry/validation token handling, frame parser, and stream-count enforcement were reviewed and appear correct. Crypto operations are delegated to rustls and validated via the crypto::PacketKey/HeaderKey traits. The crate ships four fuzz targets covering packet parsing, transport parameters, stream IDs, and streams, providing meaningful coverage of the untrusted-input surface.

Findings(1)

FINDING-1 safety low

Unsafe blocks missing SAFETY comments

All five unsafe blocks in the crate call VarInt::from_u64_unchecked, which requires the caller to guarantee that the value is less than 2^62. None of the five call sites carries a // SAFETY: comment documenting why the invariant holds.

The call sites are:

  • src/lib.rs:280From<StreamId> for VarInt: StreamId is encoded as a 64-bit integer where the top two bits encode direction and side, leaving the value well under 2^62 for any valid stream index (maximum MAX_STREAM_COUNT = 1 << 60). The invariant holds, but is not documented.
  • src/connection/send_buffer.rs:99range.start of a retransmit range: stream offsets are flow-controlled to fit within VarInt range, so this holds, but again is not stated.
  • src/connection/send_buffer.rs:118self.unsent (next unsent offset): same reasoning, not stated.
  • src/connection/mod.rs:3261frame.offset of a CRYPTO frame: crypto offsets are bounded by TLS record sizes and fit in VarInt range, but this is not documented.

The invariants are plausible from context, but the absence of // SAFETY: comments means future refactoring could silently violate them. This justifies unsafe-documented.

Annotations(7)

src/connection/paths.rs

src/connection/paths.rs, line 151-155

    /// Indicates whether we're a server that hasn't validated the peer's address and hasn't
    /// received enough data from the peer to permit sending `bytes_to_send` additional bytes
    pub(super) fn anti_amplification_blocked(&self, bytes_to_send: u64) -> bool {
        !self.validated && self.total_recvd * 3 < self.total_sent + bytes_to_send
    }

Anti-amplification limit implementation. anti_amplification_blocked returns true when total_recvd * 3 < total_sent + bytes_to_send, enforcing the RFC 9000 §8.1 requirement that servers send no more than three times the received bytes before address validation. The check is applied before each batch of outgoing datagrams in connection/mod.rs. The total_recvd counter is updated via saturating_add after each incoming datagram. Justifies protocol-impl-safe.

src/connection/streams/state.rs

src/connection/streams/state.rs, line 706-847

    pub(crate) fn received_max_streams(
        &mut self,
        dir: Dir,
        count: u64,
    ) -> Result<(), TransportError> {
        if count > MAX_STREAM_COUNT {
            return Err(TransportError::FRAME_ENCODING_ERROR(
                "unrepresentable stream limit",
            ));
        }

        let current = &mut self.max[dir as usize];
        if count > *current {
            *current = count;
            self.events.push_back(StreamEvent::Available { dir });
        }

        Ok(())
    }

    /// Handle increase to connection-level flow control limit
    pub(crate) fn received_max_data(&mut self, n: VarInt) {
        self.max_data = self.max_data.max(n.into());
    }

    pub(crate) fn received_max_stream_data(
        &mut self,
        id: StreamId,
        offset: u64,
    ) -> Result<(), TransportError> {
        if id.initiator() != self.side && id.dir() == Dir::Uni {
            debug!("got MAX_STREAM_DATA on recv-only {}", id);
            return Err(TransportError::STREAM_STATE_ERROR(
                "MAX_STREAM_DATA on recv-only stream",
            ));
        }

        let write_limit = self.write_limit();
        let max_send_data = self.max_send_data(id);
        if let Some(ss) = self
            .send
            .get_mut(&id)
            .map(get_or_insert_send(max_send_data))
        {
            if ss.increase_max_data(offset) {
                if write_limit > 0 {
                    self.events.push_back(StreamEvent::Writable { id });
                } else if !ss.connection_blocked {
                    // The stream is still blocked on the connection flow control
                    // window. In order to get unblocked when the window relaxes
                    // it needs to be in the connection blocked list.
                    ss.connection_blocked = true;
                    self.connection_blocked.push(id);
                }
            }
        } else if id.initiator() == self.side && self.is_local_unopened(id) {
            debug!("got MAX_STREAM_DATA on unopened {}", id);
            return Err(TransportError::STREAM_STATE_ERROR(
                "MAX_STREAM_DATA on unopened stream",
            ));
        }

        self.on_stream_frame(false, id);
        Ok(())
    }

    /// Returns the maximum amount of data this is allowed to be written on the connection
    pub(crate) fn write_limit(&self) -> u64 {
        (self.max_data - self.data_sent)
            // `send_window` can be set after construction to something *less* than `unacked_data`
            .min(self.send_window.saturating_sub(self.unacked_data))
    }

    /// Yield stream events
    pub(crate) fn poll(&mut self) -> Option<StreamEvent> {
        if let Some(dir) = Dir::iter().find(|&i| mem::replace(&mut self.opened[i as usize], false))
        {
            return Some(StreamEvent::Opened { dir });
        }

        if self.write_limit() > 0 {
            while let Some(id) = self.connection_blocked.pop() {
                let stream = match self.send.get_mut(&id).and_then(|s| s.as_mut()) {
                    None => continue,
                    Some(s) => s,
                };

                debug_assert!(stream.connection_blocked);
                stream.connection_blocked = false;

                // If it's no longer sensible to write to a stream (even to detect an error) then don't
                // report it.
                if stream.is_writable() && stream.max_data > stream.offset() {
                    return Some(StreamEvent::Writable { id });
                }
            }
        }

        self.events.pop_front()
    }

    /// Queues MAX_STREAM_ID frames in `pending` if needed
    ///
    /// Returns whether any frames were queued.
    pub(crate) fn queue_max_stream_id(&mut self, pending: &mut Retransmits) -> bool {
        let mut queued = false;
        for dir in Dir::iter() {
            let diff = self.max_remote[dir as usize] - self.sent_max_remote[dir as usize];
            // To reduce traffic, only announce updates if at least 1/8 of the flow control window
            // has been consumed.
            if diff > self.max_concurrent_remote_count[dir as usize] / 8 {
                pending.max_stream_id[dir as usize] = true;
                queued = true;
            }
        }
        queued
    }

    /// Check for errors entailed by the peer's use of `id` as a send stream
    fn validate_receive_id(&mut self, id: StreamId) -> Result<(), TransportError> {
        if self.side == id.initiator() {
            match id.dir() {
                Dir::Uni => {
                    return Err(TransportError::STREAM_STATE_ERROR(
                        "illegal operation on send-only stream",
                    ));
                }
                Dir::Bi if id.index() >= self.next[Dir::Bi as usize] => {
                    return Err(TransportError::STREAM_STATE_ERROR(
                        "operation on unopened stream",
                    ));
                }
                Dir::Bi => {}
            };
        } else {
            let limit = self.max_remote[id.dir() as usize];
            if id.index() >= limit {
                return Err(TransportError::STREAM_LIMIT_ERROR(""));
            }
        }
        Ok(())
    }

Stream count enforcement. validate_receive_id in streams/state.rs checks id.index() >= limit (where limit is max_remote[dir]) and returns STREAM_LIMIT_ERROR if the peer opens a stream beyond the advertised limit. received_max_streams checks that the new count does not exceed MAX_STREAM_COUNT = 1 << 60. Stream IDs from the peer that belong to locally-initiated unidirectional streams trigger STREAM_STATE_ERROR. Justifies protocol-impl-safe on stream limits.

src/constant_time.rs

Constant-time comparison for reset tokens (src/constant_time.rs). Uses #[inline(never)] and XOR-accumulation to prevent early-exit timing leaks when comparing 16-byte reset tokens. ResetToken::eq delegates to this function. Justifies crypto-safe for stateless reset token comparison.

src/endpoint.rs

src/endpoint.rs, line 42-56

pub struct Endpoint {
    rng: StdRng,
    index: ConnectionIndex,
    connections: Slab<ConnectionMeta>,
    local_cid_generator: Box<dyn ConnectionIdGenerator>,
    config: Arc<EndpointConfig>,
    server_config: Option<Arc<ServerConfig>>,
    /// Whether the underlying UDP socket promises not to fragment packets
    allow_mtud: bool,
    /// Time at which a stateless reset was most recently sent
    last_stateless_reset: Option<Instant>,
    /// Buffered Initial and 0-RTT messages for pending incoming connections
    incoming_buffers: Slab<IncomingBuffer>,
    all_incoming_buffers_total_bytes: u64,
}

Concurrency usage. Endpoint and Connection are not Send + Sync themselves; the crate is a sans-I/O state machine and the caller is expected to drive it from a single task or behind a lock. Arc is used for shared configuration (EndpointConfig, ServerConfig, TransportConfig) and for the congestion controller factory, which are all immutable or internally synchronized. StdRng in Endpoint is accessed only through &mut self. Justifies uses-concurrency, concurrency-safe, concurrency-documented.

src/packet.rs

src/packet.rs, line 577-653

    /// Decode a plain header from given buffer, with given [`ConnectionIdParser`].
    pub fn decode(
        buf: &mut io::Cursor<BytesMut>,
        cid_parser: &(impl ConnectionIdParser + ?Sized),
        supported_versions: &[u32],
        grease_quic_bit: bool,
    ) -> Result<Self, PacketDecodeError> {
        let first = buf.get::<u8>()?;
        if !grease_quic_bit && first & FIXED_BIT == 0 {
            return Err(PacketDecodeError::InvalidHeader("fixed bit unset"));
        }
        if first & LONG_HEADER_FORM == 0 {
            let spin = first & SPIN_BIT != 0;

            Ok(Self::Short {
                spin,
                dst_cid: cid_parser.parse(buf)?,
            })
        } else {
            let version = buf.get::<u32>()?;

            let dst_cid = ConnectionId::decode_long(buf)
                .ok_or(PacketDecodeError::InvalidHeader("malformed cid"))?;
            let src_cid = ConnectionId::decode_long(buf)
                .ok_or(PacketDecodeError::InvalidHeader("malformed cid"))?;

            // TODO: Support long CIDs for compatibility with future QUIC versions
            if version == 0 {
                let random = first & !LONG_HEADER_FORM;
                return Ok(Self::VersionNegotiate {
                    random,
                    dst_cid,
                    src_cid,
                });
            }

            if !supported_versions.contains(&version) {
                return Err(PacketDecodeError::UnsupportedVersion {
                    src_cid,
                    dst_cid,
                    version,
                });
            }

            match LongHeaderType::from_byte(first)? {
                LongHeaderType::Initial => {
                    let token_len = buf.get_var()? as usize;
                    let token_start = buf.position() as usize;
                    if token_len > buf.remaining() {
                        return Err(PacketDecodeError::InvalidHeader("token out of bounds"));
                    }
                    buf.advance(token_len);

                    let len = buf.get_var()?;
                    Ok(Self::Initial(ProtectedInitialHeader {
                        dst_cid,
                        src_cid,
                        token_pos: token_start..token_start + token_len,
                        len,
                        version,
                    }))
                }
                LongHeaderType::Retry => Ok(Self::Retry {
                    dst_cid,
                    src_cid,
                    version,
                }),
                LongHeaderType::Standard(ty) => Ok(Self::Long {
                    ty,
                    dst_cid,
                    src_cid,
                    len: buf.get_var()?,
                    version,
                }),
            }
        }
    }

Packet header parsing (ProtectedHeader::decode) validates length bounds at every step: token length is checked against remaining buffer, payload length is checked against datagram length, and malformed CIDs return InvalidHeader. Frame iteration (frame::Iter) validates each frame type and uses checked_sub in scan_ack_blocks to prevent ACK block underflow. Empty payloads are rejected per RFC 9000. Justifies parser-impl-safe, parser-impl-tested.

src/token.rs

src/token.rs, line 113-190

impl IncomingToken {
    /// Construct for an `Incoming` given the first packet header, or error if the connection
    /// cannot be established
    pub(crate) fn from_header(
        header: &InitialHeader,
        server_config: &ServerConfig,
        remote_address: SocketAddr,
    ) -> Result<Self, InvalidRetryTokenError> {
        let unvalidated = Self {
            retry_src_cid: None,
            orig_dst_cid: header.dst_cid,
            validated: false,
        };

        // Decode token or short-circuit
        if header.token.is_empty() {
            return Ok(unvalidated);
        }

        // In cases where a token cannot be decrypted/decoded, we must allow for the possibility
        // that this is caused not by client malfeasance, but by the token having been generated by
        // an incompatible endpoint, e.g. a different version or a neighbor behind the same load
        // balancer. In such cases we proceed as if there was no token.
        //
        // [_RFC 9000 § 8.1.3:_](https://www.rfc-editor.org/rfc/rfc9000.html#section-8.1.3-10)
        //
        // > If the token is invalid, then the server SHOULD proceed as if the client did not have
        // > a validated address, including potentially sending a Retry packet.
        let Some(retry) = Token::decode(&*server_config.token_key, &header.token) else {
            return Ok(unvalidated);
        };

        // Validate token, then convert into Self
        match retry.payload {
            TokenPayload::Retry {
                address,
                orig_dst_cid,
                issued,
            } => {
                if address != remote_address {
                    return Err(InvalidRetryTokenError);
                }
                if issued + server_config.retry_token_lifetime < server_config.time_source.now() {
                    return Err(InvalidRetryTokenError);
                }

                Ok(Self {
                    retry_src_cid: Some(header.dst_cid),
                    orig_dst_cid,
                    validated: true,
                })
            }
            TokenPayload::Validation { ip, issued } => {
                if ip != remote_address.ip() {
                    return Ok(unvalidated);
                }
                if issued + server_config.validation_token.lifetime
                    < server_config.time_source.now()
                {
                    return Ok(unvalidated);
                }
                if server_config
                    .validation_token
                    .log
                    .check_and_insert(retry.nonce, issued, server_config.validation_token.lifetime)
                    .is_err()
                {
                    return Ok(unvalidated);
                }

                Ok(Self {
                    retry_src_cid: None,
                    orig_dst_cid: header.dst_cid,
                    validated: true,
                })
            }
        }
    }

Retry and validation token handling. Tokens are AEAD-encrypted (via HandshakeTokenKey::aead_from_hkdf) with a per-token nonce included in the wire encoding. Retry tokens are validated against the client's source address and an expiry time (retry_token_lifetime). Validation tokens additionally check the token log (TokenLog::check_and_insert) for reuse prevention. An invalid retry token causes INVALID_TOKEN close; an invalid validation token degrades to unvalidated status rather than failing. Justifies uses-crypto and crypto-safe for token handling.

src/varint.rs

src/varint.rs, line 39-46

    /// Create a VarInt without ensuring it's in range
    ///
    /// # Safety
    ///
    /// `x` must be less than 2^62.
    pub const unsafe fn from_u64_unchecked(x: u64) -> Self {
        Self(x)
    }

Four of the five unsafe blocks in the crate call VarInt::from_u64_unchecked. The function's contract requires the argument to be less than 2^62. Stream offsets, stream IDs, and CRYPTO frame offsets are all bounded by QUIC flow control and the VarInt address space such that this invariant holds structurally, but no // SAFETY: comment documents this reasoning. The blocks are the only unsafe in the codebase. The invariants were reviewed and hold; see FINDING-1. Justifies uses-unsafe, unsafe-safe, unsafe-minimal.