Skip to content

Add a QUIC transport (feature-gated)#110

Draft
plaidfinch wants to merge 18 commits into
mainfrom
quic
Draft

Add a QUIC transport (feature-gated)#110
plaidfinch wants to merge 18 commits into
mainfrom
quic

Conversation

@plaidfinch

Copy link
Copy Markdown

CAVEAT LECTOR: This PR was largely put together by Claude Fable 5, and is an experiment that deserves more discussion before merging.

Summary

Adds a QUIC transport to sprockets-tls as a purely additive, default-off cargo feature (quic). QUIC connections carry the exact same mutually-authenticated, RoT-attested channel as the TCP API: same rustls configs, same crypto-provider pins (X25519, ChaCha20-Poly1305, TLS 1.3 only), same RotCertVerifier/CertResolver, same attestation protocol bytes over a QUIC bidirectional stream, same PlatformId binding. No existing signature changes; the no-feature build resolves an unchanged dependency graph (cargo tree | grep -c quinn = 0).

The core primitive is quic::Endpoint — one UDP socket that both dials and listens (QUIC-natural for the trust-quorum full mesh) — plus thin quic::Client/quic::Server facades mirroring the TCP API. See the quic module docs for the full security model, close-code table, and liveness/drop semantics.

Stacked on

The first three commits are stand-ins for #107, #108, #109 (patch-identical). Once those squash-merge, a plain git rebase main drops them automatically by patch-id. Recommendation: review primarily the commits from "Extract pure TLS-config construction…" onward.

Design decisions

  • API shape: unified Endpoint as the core + Client/Server facades.
  • Packaging: feature flag, default off; pub use quinn re-export so consumers get version-matched types (semver-lockstep obligation documented in module docs).
  • quinn types in the public API: deliberately chosen to avoid re-implementing the surface area of quinn.

Security posture (including mitigations for differences from TCP)

  • RFC 9001 Initial packets use AES-128-GCM (mandated by protocol; keys derived from public values; no confidentiality/authentication claim). The handshake still offers only ChaCha20-Poly1305, via QuicClientConfig::with_initial. Pinned by two tests.
  • ALPN b"sprockets" (frozen wire constant, not versioned), with strict matching.
  • Resumption/0-RTT explicitly disabled; server migration(false) to bind the connection to the initial address, as TLS is inherently bound.
  • Pre-auth UDP surface bounded: Retry-based address validation, MAX_INCOMING = 256, 10 MiB pre-auth buffer (vs quinn's 65,536 / 100 MB defaults).
  • Client-auth gating: connection obtained only by awaiting the full Connecting future (regression-tested: a certificate-less client never reaches accept_bi).

Test coverage

12 QUIC integration tests (all invariants doc-commented): basic attested echo, permissive/no-corpus, unattested client rejected, malformed/oversized protocol messages rejected (close-code observed), concurrent accepts, multi-stream identity inheritance, appraisal-failure close code, mandatory client auth, PlatformId mismatch rejected, wrong ALPN rejected, AES-session-suite offer rejected, 1 MiB payload round-trip, Initial-suite unit pin.

Plus an end-to-end test of the example pair itself (tls/tests/quic_examples.rs): spawns the server_quic/client_quic binaries as separate processes over loopback, asserts complete echoes for two sequential clients and that the server outlives them (regression for a close-semantics panic the first manual run of the examples caught).

Verification

  • illumos: clippy --all-targets clean with both feature configs; 8/8 TCP tests, 22/22 with quic.
  • Example pairs run end-to-end on illumos loopback: QUIC client↔server (also as a permanent integration test) and TCP pair cross-check.
  • rustdoc warnings-clean, public and --document-private-items passes.
  • Every commit on the branch builds (cargo check --all-targets, bisectable).
  • Cargo.lock: no existing package changes; additions only (audited).

Open questions for discussion

  1. Liveness constants: MAX_IDLE_TIMEOUT 30 s / KEEP_ALIVE_INTERVAL 10 s: this is sized around trust-quorum's 1 s pings and 10 s inactivity timeout; needs sign-off from trust-quorum owners.
  2. Hardening constants: MAX_INCOMING 256, INCOMING_BUFFER_SIZE_TOTAL 10 MiB: is this permissive enough for the worst-cases on a 32-sled rack?
  3. MTU on real hardware: QUIC needs path MTU ≥ 1200; verified nothing on the actual bootstrap vnics (underlay is 9000, some vnics 1500). Needs a check during rack testing.

The server parsed the client's version message with version_bytes[..4],
which panics on a body shorter than 4 bytes — a remotely triggerable panic
for any TLS-authenticated peer, and process death under panic = "abort".
Parse with a checked conversion and reject anything but exactly 4 bytes as
Error::ProtocolVersion.

This is stricter than the old code in one way: a version message longer
than 4 bytes was previously accepted with the trailing bytes ignored. No
client has ever sent one (both protocol versions send exactly
CURRENT_PROTOCOL_VERSION.to_le_bytes()), so no interoperability change.
recv_msg allocated the full message buffer from the peer-controlled length
prefix before reading any of the body, so a TLS-authenticated (but not yet
attested) peer could demand a 4 GiB zeroed allocation with a 4-byte prefix,
and hold it by leaving the connection open — repeatable across connections
for memory exhaustion. Authenticated-but-unattested is exactly the
adversary class the attestation layer exists to screen out, so the framing
layer must not trust it with allocation sizes.

Bound messages at a named MAX_MSG_SIZE (1 MiB), with compile-time asserts
that the bound admits the largest legitimate messages (the hubpacked
measurement log and attestation). Oversized lengths are rejected as a new
Error::MessageTooLarge before any allocation. No interoperability change:
every legitimate protocol message is far below the bound.
cargo clippy --all-targets -- -D warnings fails on illumos (where the
cfg(target_os) build-script lines and the unittest build path compile) on
lints the ubuntu CI run never sees: uninlined format args in build.rs and
a needless Ok(..?) wrapper, plus roots.len() < 1 in both examples. Inline
the format args, drop the wrapper, and use is_empty(). No behavior
change.
Factor the four rustls config builders down to two transport-free
functions and dedupe the peer-PlatformId extraction, in preparation for
adding a second transport (QUIC) that will consume the same
configuration and identity plumbing.

- Add tls/src/config.rs with pub(crate) load_roots,
  new_tls_client_config, and new_tls_server_config. The old
  new_tls_{local,ipcc}_{client,server}_config builders differed only in
  the ResolveSetting passed to CertResolver::new, so they collapse to
  one client and one server builder parameterized by ResolveSetting.
- Add platform_id_from_tls_certs in lib.rs, replacing the two
  hand-rolled peer-certificate-chain walks in client.rs and server.rs.
  It takes rustls' Option<&[CertificateDer]>, so it serves both TCP's
  peer_certificates() and (later) QUIC's peer_identity().
- Client::connect and Server::new no longer load the root PEMs twice.
  A missing or malformed root now surfaces as Error::FailedRead{path}
  via load_root_cert, rather than a bare Error::Io.
- Correct the stale "use ring; aws_lc doesn't compile on illumos"
  comment on crypto_provider(): the crate uses aws-lc-rs, and it does
  build and run on illumos (verified natively).

No change to TLS wire behavior, the crypto provider, the verifiers, or
the attestation protocol. TCP tests pass on macOS and natively on
illumos.
Factor the post-handshake attestation exchange out of client.rs and
server.rs into a new attest module, in preparation for running the same
protocol over a QUIC bidirectional stream as well as TCP.

- Add tls/src/attest.rs holding the framing (send_msg/recv_msg with its
  MAX_MSG_SIZE bound), the DER cert-chain helpers
  (certs_to_der/certs_from_der), the protocol version constants and result
  aliases, and corims_from_paths, all moved from lib.rs and server.rs.
- Extract client_exchange and server_exchange, each generic over
  T: AsyncRead + AsyncWrite + Unpin. They take the peer PlatformId already
  derived from the TLS handshake certificates and return the peer's
  attested PlatformId plus whether its measurements appraised. The bodies
  are the former inline exchanges verbatim, including the checked
  version-message parse and preserving the wire-visible asymmetries: the
  server sends its own attestation last (and never, under Enforced, after
  a failed appraisal), the client loads its corims inline at the end of
  the exchange, and the server loads its corpus before accepting the TLS
  connection so a malformed corpus fails early.
- Client::connect_with_config and SprocketsAcceptor::handshake shrink to
  TLS setup, peer-PlatformId extraction, and a call into attest.

No change to the attestation protocol bytes, the TLS configuration, or the
crypto provider. Verified on illumos: clippy --all-targets clean and the
TCP test suite passes.
Gate the forthcoming QUIC transport behind a quic feature that pulls in
quinn as an optional dependency. Nothing uses quinn yet; this is the
additive packaging step so the module can land next without touching the
no-feature build.

- Add quinn to the workspace dependencies with the tokio runtime and the
  aws-lc-rs rustls backend, matching the crypto provider sprockets already
  uses.
- Add quinn as an optional dependency of sprockets-tls behind
  quic = ["dep:quinn"].

The Cargo.lock update is additions-only: no existing crate's resolved
version changes, and `cargo tree -p sprockets-tls` without the feature
still resolves zero quinn crates. Verified on illumos that both the
no-feature build and the --features quic build compile natively with the
aws-lc-rs backend (quinn, quinn-proto, and quinn-udp's solarish path).
Add a feature-gated quic module carrying the same mutually-authenticated,
RoT-attested channel as the TCP API over QUIC. The transport reuses the
crate's rustls config construction and the transport-generic attestation
exchange unchanged; only the framing underneath differs.

- quic.rs: the Endpoint primitive (one UDP socket that both connects and
  accepts), the Acceptor, the close_code table, and the quinn config
  assembly. The TLS config is the same one the TCP path builds, plus ALPN
  b"sprockets", disabled resumption/0-RTT, and a QuicClientConfig /
  QuicServerConfig wrapping it with the RFC 9001 AES-128-GCM Initial suite
  (the handshake still offers only ChaCha20-Poly1305). Transport policy
  (idle timeout, keep-alive) and server hardening (migration off, bounded
  pre-auth incoming, Retry-based address validation) are named constants.
- quic/stream.rs: BiStream, which rejoins quinn's split send/recv halves
  into one AsyncRead + AsyncWrite duplex, and AttestedConnection, the QUIC
  analog of Stream<T> whose attested identity covers every stream opened on
  the connection.
- Four #[cfg(feature = "quic")] Error variants for the QUIC failure modes.

The handshake awaits the full connection future, never quinn's into_0rtt
path, so a connection is surfaced only after the client's certificate is
verified. Verified on illumos: builds, clippy --all-targets, and rustdoc
(public and private) are clean with the feature, and the TCP suite passes
with and without it.
Thin facades over the quic Endpoint that mirror the TCP Client and Server
APIs, for callers that only dial or only listen.

- quic::Client::connect binds an ephemeral endpoint on [::]:0, runs the
  attested handshake, and returns the connection. The endpoint handle is
  then dropped; quinn keeps its I/O driver alive while the returned
  connection lives (the EndpointDriver only terminates once no endpoint
  handles and no connections remain), so the connection stays usable.
  Callers that dial repeatedly or also listen should share one Endpoint.
- quic::Server wraps a listening Endpoint with new / listen_addr / accept,
  plus an endpoint() accessor for callers that also need to dial or close.

Verified on illumos: clippy --all-targets, rustdoc (public and private),
and the TCP suite are clean with the feature.
Twelve tests for the quic module, reusing the crate's test PKI and the
shared helpers in crate::tests (local_config is made pub for this).

Transport and exchange wiring:
- basic, no_corpus: enforcing and permissive handshakes with a message echo
- unattested_client: valid TLS but no attestation exchange is rejected
- spawn_accept: several concurrent clients on one endpoint
- multi_stream: a second stream inherits the connection's attested identity
- appraisal_failure_close_code: the client observes the APPRAISAL close code
- client_auth_required: a client with no certificate is rejected

Security pins:
- platform_id_mismatch: a peer whose TLS and attestation chains disagree on
  PlatformId is rejected, and the client observes PLATFORM_ID_MISMATCH
- session_cipher_pinned_to_chacha20: a client offering AES-GCM session suites
  is rejected (the RFC 9001 AES-128-GCM Initial suite does not satisfy the
  ChaCha20-only session pin)
- wrong_alpn_rejected: a client offering the wrong ALPN token is rejected

Robustness:
- large_payload: 1 MiB round-trips intact both directions, exercising QUIC
  stream flow control and the BiStream duplex

- initial_suite_is_aes_128_gcm: unit test pinning the Initial suite

Two behaviors surfaced while writing these: dropping a connection handle
before the peer has read closes it with code 0 and truncates undelivered
data (so the tests keep connections alive until reads complete, matching the
module's delivery-assurance note), and the two test configs provision
distinct platforms. Verified on illumos: clippy --all-targets and the full
suite pass with and without the feature.
The QUIC analogs of the client and server examples: same CLI and stdin/echo
behavior, over a sprockets QUIC connection instead of TCP.
AttestedConnection's AsyncRead/AsyncWrite impls let tokio::io::split and
copy work as with Stream<TcpStream>; quic::Server::new is synchronous.

The shutdown flow deliberately differs from the TCP examples, demonstrating
the drop semantics from the quic module docs: the client drains the echo to
stream EOF after stdin ends (exiting on stdin close would drop the
connection and could truncate the in-flight echo), and the server finishes
its stream then holds the connection until the client closes, treating a
client that departs by connection close as the end of that connection
rather than a fatal error.

Both examples are gated behind required-features = ["quic"], so a
no-feature --all-targets build skips them and Cargo.lock is unchanged.
Verified on illumos: clippy --all-targets clean with the feature, and the
example pair run against each other over loopback: two sequential clients,
full echoes with no timing dependence, server alive throughout.
Add a features matrix to the clippy and build-and-test jobs so both the
default (quic off) and --features quic configurations are linted, built, and
tested on every run. check-style is left single because rustfmt is
feature-independent, and clippy keeps its existing target scope: --all-targets
would enable the unittest feature whose build.rs needs attest-mock, which only
the build-and-test job installs (and which already builds all targets and runs
the tests in both configurations).
Previously only attestation-exchange failures closed the connection with a
close code from close_code; failures between connection establishment and
the exchange (peer-identity extraction, opening or accepting the primary
stream) returned early and let the dropped connection close with code 0,
indistinguishable from a graceful close at the peer. Split the
post-establishment work into client_handshake/server_handshake helpers so
Endpoint::connect and Acceptor::handshake close with the mapped code on any
failure past establishment.
The close reason previously carried the rendered error message, which for
local failures can include detail the peer has no need for (file paths,
configuration specifics). Close with a short static reason naming only the
failure class.

Hubpack decode failures, DER parse failures, and oversized length prefixes
during the exchange arise from peer-sent bytes (the encode directions
cannot fail on our own, already-validated data), so map them to PROTOCOL /
ATTESTATION / PROTOCOL respectively instead of letting them fall through
to LOCAL_ERROR.
Client::connect went through Endpoint::new, which always installs a server
configuration, so the facade's ephemeral socket would field and buffer
inbound QUIC connection attempts it could never accept. Bind via
quinn::Endpoint::client instead: no server configuration, no listening
surface. Endpoint::new is unchanged for callers that want the dual-role
socket.
A second shutdown().await on a BiStream errors (quinn's finish rejects an
already-finished stream), unlike the TCP-backed Stream; state this on the
type since BiStream is pitched as a drop-in duplex. Also note why the
retry() result in Endpoint::accept is safe to discard.
QUIC-side pins for the two attest-protocol hardening fixes, mirroring the
TCP tests: a raw quinn client with valid credentials sends a version
message shorter than 4 bytes (must be rejected as ProtocolVersion, not a
panic) and a length prefix claiming a 4 GiB message (must be rejected as
MessageTooLarge on the prefix alone, with the client observing the
PROTOCOL close code).
A reader landing on the crate root had no way to discover the feature-gated
quic module. Plain code span rather than an intra-doc link: the module is
conditionally compiled, so a link would break the no-feature doc build.
The examples are part of the crate's contract, and running them caught a
real bug (the server panicking when a client departed), so pin them with an
end-to-end integration test: spawn server_quic on [::1]:0, read the
announced address, run client_quic twice, and assert each client's stdin
comes back complete on stdout and that the server outlives its clients.

server_quic now announces its bound address on stdout: with port 0 that is
the only way any caller, interactive or test, can learn the OS-assigned
port. Cargo provides no CARGO_BIN_EXE_<name> for examples, so the test
locates the binaries relative to the test executable; every wait is bounded
so a wedged process fails the suite rather than hanging it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant