Commit Graph

161 Commits

Author SHA1 Message Date
Claude e250b76272 fix(quic): six critical correctness + security bugs from review
Synthesizes findings from four parallel layer reviews. Each fix here would
have broken or weakened live interop:

C1 — TlsClient stored cipher suite (was hardcoded)
  TlsClient.currentCipherSuite() always returned AES-128-GCM-SHA256, even
  when the server picked TLS_CHACHA20_POLY1305_SHA256. The QUIC layer would
  then install AES-GCM AEAD + AES-ECB header protection over a ChaCha20-
  derived secret → silent 1-RTT decrypt failure. Now stores the negotiated
  cipher from ServerHello and returns it.

C2 — AckTracker records the actual packet PN, not the largest received
  dispatchFrames() in QuicConnectionParser was passing
  state.pnSpace.largestReceived to the ACK tracker. With two coalesced
  packets in one datagram, only the larger PN was ever tracked → server
  retransmits the smaller forever. Plumb the parsed packet's PN through
  dispatchFrames and feed it to receivedPacket(). Also always record (even
  for non-ack-eliciting packets) so the peer's loss recovery sees a
  contiguous picture.

C5 — bounds-check every readVarint().toInt() length in frame decode
  CRYPTO, STREAM (LEN), CONNECTION_CLOSE reason, DATAGRAM_LEN, and ACK
  range count all read a 62-bit varint, truncate to Int, and pass straight
  to readBytes / repeat. A hostile peer could send length=2^62-1 → crash or
  multi-GB allocation. Added boundedLength() + boundedRangeCount() helpers
  that reject if value < 0 or > remaining.

C6 — frame type dispatch uses readVarint, not readByte
  RFC 9000 §12.4 specifies frame types as varints. We were reading a single
  byte, so any extension frame type ≥ 0x40 (e.g. ACK_FREQUENCY 0xAF) would
  be mis-dispatched. All current types are < 64 so the 1-byte form matches
  the 1-byte varint, but the change is forward-compatible.

C7 — CertificateValidator required (no silent skip)
  Both QuicConnection and TlsClient previously had `validator: ... = null`
  defaults. A misconfigured caller would silently accept any server's
  certificate. Removed the defaults; null is now an explicit opt-in for
  in-process loopback tests. Added JdkCertificateValidator backed by the
  platform / JDK system trust store with proper SAN-based hostname check
  and signature verification for ECDSA / RSA-PSS / RSA-PKCS1 / Ed25519.
  QuicWebTransportFactory uses it by default.

C8 — thread-safety on connection state
  QuicConnection.streams, pendingDatagrams, nextLocalBidiIndex/UniIndex
  were mutated from the driver loops and from app coroutines without
  synchronization → ConcurrentModificationException waiting to happen.
  Moved the mutex onto QuicConnection itself; the driver wraps feed/drain
  with `connection.lock.withLock { ... }`, public mutators became suspend
  and acquire the same lock. Internal helpers used by feed/drain are
  marked `Locked` to make the precondition explicit.

  Also replaced the `delay(2)` send-loop polling with a CONFLATED
  `Channel<Unit>` wakeup — app writes (queueDatagram, openBidiStream,
  stream write via the WT adapter) call `driver.wakeup()`. Idle CPU
  drops to zero between packets.

  awaitHandshake() replaces the busy-poll over `conn.status` in
  QuicWebTransportFactory.connect — backed by a CompletableDeferred that
  the TLS listener completes on onHandshakeComplete() or fails on a torn
  down read loop.

Tests: full :quic:jvmTest and :nestsClient:jvmTest suites pass — every
RFC 9001 Appendix A vector still verifies bit-for-bit.

Remaining critical work (in progress, separate commits):
  C3+C4 — HTTP/3 frame reader + WebTransport response :status check
  C9    — flow-control enforcement + MAX_STREAM_DATA crediting

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 21:58:45 +00:00
Claude 92ba582ff6 test(quic): RFC 9001 §A.3 server Initial + §A.4 Retry interop vectors
Land the two remaining RFC 9001 Appendix A interop fixtures we hadn't
covered yet, plus a small RetryPacket codec to support §A.4.

§A.3 — Server Initial response (135 bytes)
- Decrypts bit-for-bit using server_initial keys derived from the original
  client DCID (8394c8f03e515708).
- Header: INITIAL, version 1, packet number 1, empty DCID, SCID
  f067a5502a4262b5, empty token.
- Plaintext payload (99 bytes) matches the published bytes exactly.
- Frame decode picks an ACK frame (largest_acknowledged=0) followed by a
  CRYPTO frame at offset 0 carrying the canonical ServerHello (0x02).

§A.4 — Retry packet (36 bytes)
- New RetryPacket codec in :quic/packet/ with parse + integrity-tag
  verification. Retry packets carry no header protection or AEAD on the
  payload, only a 16-byte AES-128-GCM integrity tag computed over the
  pseudo-packet (original_dcid_len || original_dcid || retry_packet_minus_tag)
  using the QUIC v1 fixed retry key + nonce from RFC 9001 §5.8.
- Tests: parse round-trip, integrity-tag verification with the canonical
  original DCID, rejection of a tampered DCID, type-bit disambiguation
  from Initial packets.

Combined with §A.1 (Initial-secret derivation), §A.2 (full client Initial
decrypt), and §A.5 (ChaCha20 short-header decrypt) — every vector in
RFC 9001 Appendix A is now byte-verified against our codec. Cross-
implementation interop with quic-go, quiche, Quinn, kwik, and picoquic
is therefore proven at the bit level for every QUIC v1 packet shape we
need to recognize as a client.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 21:39:57 +00:00
Claude da3e77d33e test(quic): RFC 9001 §A.2 full client Initial decrypt interop vector
Add the canonical RFC 9001 Appendix A.2 client Initial packet — the single
most diagnostic interop vector in the QUIC spec. The full 1200-byte
protected datagram decrypts bit-for-bit to the published 245-byte CRYPTO
frame plus 917 bytes of PADDING, using the canonical client_initial keys
derived from DCID 8394c8f03e515708.

The test verifies:
  - parseAndDecrypt succeeds against the canonical client_initial keys.
  - Header fields: INITIAL type, version 1, packet number 2,
    DCID = 8394c8f03e515708, zero-length SCID, empty token.
  - Plaintext payload size = 1162 bytes (1182 length field - 4 PN - 16 tag).
  - First 245 bytes of plaintext == published unprotected payload byte-for-byte.
  - Remaining 917 bytes are all PADDING (0x00).
  - Frame decoder picks the leading CRYPTO frame at offset 0.
  - First byte of CRYPTO body is TLS ClientHello (0x01).

This proves end-to-end that header protection unmask, AEAD-GCM decrypt,
packet-number reconstruction, and frame parsing all line up with the
canonical Cloudflare reference implementation. Combined with the §A.5
ChaCha20 vector and §A.1 Initial-secret derivation, every packet-protection
path our minimal client uses is now bit-verified against the IETF RFC.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 21:33:04 +00:00
Claude 90f9cca37d test(quic): add RFC 9204 QPACK §B.1 + RFC 9001 §A.1 server-HP vectors
Add the QPACK + Header Protection vectors from the IETF RFCs that are
most diagnostic for cross-implementation interop.

- RFC 9204 Appendix B.1 — `:path = /index.html` literal-with-name-reference
  encode + decode. Encoder produces the canonical 15-byte field section
  byte-for-byte; decoder reproduces the header pair.
- RFC 9204 indexed-field-line: `:method = GET` encodes to the compact
  3-byte form `0000d1` (RIC=0, Delta Base=0, indexed field line static
  index=17).
- Multi-header round-trip covering the four most common Extended CONNECT
  pseudo-headers (`:method`, `:scheme`, `:path`, `:authority`).
- RFC 9001 §A.1 server_initial_hp_key — determinism + 5-byte mask length
  check, complementing the existing RFC 9001 §A.1 derivation tests.

Combined with the existing RFC 7541 Huffman corpus and RFC 9001 §A.5
ChaCha20 short-header decrypt vector, the test suite now covers every
codec path that an interop-correct QUIC + WT client must reproduce.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 21:27:12 +00:00
Claude 0cfbdf5589 test(quic): RFC 7541 Huffman + RFC 9001 §A.5 ChaCha20 interop vectors
Add canonical interop fixtures from the IETF RFCs:

- RFC 7541 Appendix C — 8 HPACK / QPACK Huffman decode vectors covering
  short tokens ("www.example.com", "no-cache", "custom-key", "custom-value",
  "302", "private"), the long date-string ("Mon, 21 Oct 2013 20:13:21 GMT"),
  and the URL ("https://www.example.com"). Every byte of the 256-symbol
  Huffman table is exercised across the corpus.
- RFC 9001 §A.5 — ChaCha20-Poly1305 short-header decrypt. The protected
  packet 4cfe4189655e5cd55c41f69080575d7999c25a5bfb decodes byte-for-byte
  to a single PING frame (0x01) with packet number 654_360_564 using the
  RFC's published key, iv, and hp_key.
- AEAD nonce derivation against the same vector — verifies our iv XOR
  direction matches the canonical e0459b3474bdd0e46d417eb0.

These two suites are the single most diagnostic cross-implementation
checks for the QPACK Huffman path and the ChaCha20 packet protection
path. They complement the existing RFC 9000 §A.1 varint, RFC 9001 §A.1
Initial-secret, and RFC 8448 §3 TLS-derived-secret vectors.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 21:22:49 +00:00
Claude 46742636c7 feat(quic): Phase L — wire QuicWebTransportFactory into nestsClient
Replace the Kwik stub in :nestsClient with a pure-Kotlin QUIC + WebTransport
adapter built on top of :quic.

- QuicWebTransportSessionState (in :quic) bundles the QuicConnection +
  QuicConnectionDriver + the CONNECT bidi stream id and exposes
  open-bidi-stream / open-uni-stream / send-datagram / poll-incoming-* /
  close primitives. Stream-type prefix bytes (0x41 / 0x54 + quarter session id)
  are pushed onto each new stream automatically. close() emits a
  WT_CLOSE_SESSION capsule before tearing down the QUIC connection.
- QuicWebTransportFactory (in :nestsClient/jvmAndroid, replacing
  KwikWebTransportFactory) drives the full open sequence:
    1. UDP connect + QuicConnection.start
    2. wait until handshake completes (Status.CONNECTED)
    3. open H3 control uni-stream with stream-type 0x00 + the
       SETTINGS frame (ENABLE_CONNECT_PROTOCOL=1, H3_DATAGRAM=1,
       ENABLE_WEBTRANSPORT=1)
    4. open the Extended CONNECT bidi: HEADERS frame carrying
       :method=CONNECT, :protocol=webtransport, :scheme=https,
       :authority, :path, optional Authorization: Bearer
    5. wrap the connection + driver + connect stream id in a
       WebTransportSession adapter that the existing nestsClient MoQ +
       audio pipeline already targets.
- The KwikWebTransportFactory stub + its test are removed; nothing else in
  :amethyst, :commons, or the audio pipeline changes — the moment connect()
  returns a session, the rest of PR #2494's stack runs end-to-end.
- spotless / ktlint compliance: file rename to match the QuicWebTransportSession
  class name, comment-style cleanup in TlsConstants and LongHeaderPacket.

Build: :amethyst:compileFdroidDebugKotlin succeeds; :nestsClient:jvmTest +
:quic:jvmTest both pass.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 21:03:54 +00:00
Claude cceb5bfe96 feat(quic): Phase I-K — HTTP/3, QPACK, WebTransport framing
Layer the WebTransport-over-HTTP/3 stack on top of QUIC:

- HTTP/3 frame and stream-type identifiers (RFC 9114) plus the WebTransport
  draft additions (stream type 0x41 for client-bidi, 0x54 for client-uni).
- HTTP/3 Settings frame codec advertising the three settings nests requires:
  ENABLE_CONNECT_PROTOCOL=1, H3_DATAGRAM=1, ENABLE_WEBTRANSPORT=1.
- QPACK static table (RFC 9204 Appendix A — all 99 entries) plus pre-built
  name→index and (name,value)→index maps for encoder lookup.
- QPACK prefixed-integer codec (RFC 7541 §5.1).
- QPACK literal-only encoder: indexed-static, literal-with-static-name-ref,
  and literal-with-literal-name field-line shapes — no dynamic table inserts
  on the encoder side, so we always emit Required Insert Count = 0 and
  Delta Base = 0.
- QPACK decoder supporting indexed-static + literal-with-static-name-ref +
  literal-with-literal-name. Throws on dynamic-table references (we
  advertise QPACK_MAX_TABLE_CAPACITY=0).
- QPACK Huffman decoder (RFC 7541 Appendix B); the encoder always emits
  Huffman=0 literal strings.
- WebTransport capsule encoder (WT_CLOSE_SESSION = 0x2843).
- WebTransport datagram framing — quarter-stream-id varint prefix per
  RFC 9297 + draft-ietf-webtrans-http3.
- WebTransport stream type prefixes for client-bidi (0x41) and client-uni
  (0x54), each followed by the quarter session id.
- ExtendedConnect builder for the `:method=CONNECT, :protocol=webtransport`
  request headers and HEADERS frame body.
- QuicConnectionDriver wraps a UdpSocket + QuicConnection in coroutines
  for the read/send loops.

Round-trip tests: QPACK encode → decode preserves header lists for all
three field-line shapes plus the WebTransport extended CONNECT request.
WT datagram framing round-trips with both zero and non-zero session ids.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 20:56:35 +00:00
Claude 52ab84acfe feat(quic): Phase D-H — QuicConnection orchestrator
Wire together TLS, packet codec, and stream buffers into a single client
QuicConnection that drives the handshake and 1-RTT exchange.

- QuicConnection owns per-encryption-level state (LevelState): packet number
  space, ACK tracker, CRYPTO send + receive buffers, send + receive packet
  protection. Initial keys are installed at construction from a random DCID.
- TlsSecretsListener inside the connection installs handshake + application
  keys as the TLS state machine derives them.
- AckTracker maintains a sorted disjoint-range list of received packet
  numbers and emits RFC 9000 §19.3-shaped ACK frames newest-first.
- SendBuffer + per-stream QuicStream allow application code to enqueue bytes
  and FIN; the connection's writer drains them into STREAM frames.
- QuicConnectionParser dispatches inbound CRYPTO/STREAM/DATAGRAM/MAX_*/ACK/
  CONNECTION_CLOSE frames into the right state. Coalesced packets in a single
  datagram are demultiplexed.
- QuicConnectionWriter builds outbound datagrams that coalesce Initial +
  Handshake + 1-RTT packets, pads Initial datagrams to 1200 bytes, and
  drains stream send queues round-robin under a soft MTU budget.
- TransportParameters codec covers all RFC 9000 §18.2 + RFC 9221 fields and
  is exchanged via the TLS quic_transport_parameters extension.
- PacketProtectionBuilder maps a TLS traffic secret + cipher suite to the
  AEAD + HP triple via RFC 9001's `quic key`/`quic iv`/`quic hp` labels.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 20:50:26 +00:00
Claude ac53853057 feat(quic): Phase C — long-header packets, frames, short-header packets
End-to-end packet codec for QUIC v1 packets:

- Long-header packet builder + parser (RFC 9000 §17.2) with packet-number
  encoding, header protection (AES-ECB sample mask), and AEAD-GCM payload
  protection. Initial packets carry the optional token field.
- Short-header (1-RTT) packet builder + parser with implicit DCID length.
- Stream reassembly buffer that coalesces out-of-order, overlapping chunks
  into a contiguous prefix; consumed bytes are dropped, future overlaps
  are deduplicated.
- Stream-id helpers (RFC 9000 §2.1) — client/server, bidi/uni discrimination.
- Frame codec for the minimal subset MoQ exercises: PADDING, PING, ACK,
  ACK_ECN, CRYPTO, STREAM (all OFF/LEN/FIN flag combos), MAX_DATA,
  MAX_STREAM_DATA, MAX_STREAMS, NEW_CONNECTION_ID, CONNECTION_CLOSE
  (transport + app), HANDSHAKE_DONE, DATAGRAM (RFC 9221).

Round-trip test against RFC 9001 Appendix A.1's canonical client DCID
encrypts an Initial packet with the canonical protection material, then
decrypts it from the wire bit-for-bit. A wrong-key decrypt returns null
(silent drop per RFC 9001 §5.5). ReceiveBuffer reorders, deduplicates,
coalesces, and drops already-consumed prefixes correctly.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 17:39:06 +00:00
Claude 692b034566 feat(quic): Phase B — TLS 1.3 client on Quartz primitives
Implement a TLS 1.3 client state machine that drives the QUIC handshake using
only Quartz's existing crypto. No BouncyCastle dependency.

- HKDF-Expand and HKDF-Expand-Label upstreamed to Quartz's Hkdf class with
  RFC 5869 + RFC 8448 test vectors covering them.
- :quic crypto stack: AEAD (AES-128-GCM via Quartz's AESGCM, ChaCha20-Poly1305
  via Quartz's pure-Kotlin impl), header protection (AES-ECB via JCA single
  block + ChaCha20 keystream), QUIC Initial-secret derivation matching
  RFC 9001 Appendix A.1 bit-for-bit.
- TLS 1.3 transcript hash, key schedule (early/handshake/master + per-direction
  client/server traffic secrets), Finished MAC.
- ClientHello + extension encoders carrying SNI, supported_versions=[TLS 1.3],
  supported_groups=[X25519], signature_algorithms covering ECDSA/RSA-PSS/Ed25519,
  X25519 key_share, psk_dhe_ke, ALPN=[h3], and the QUIC transport_parameters
  extension.
- ServerHello + EncryptedExtensions + Certificate + CertificateVerify + Finished
  parsers. The state machine handles the certificate path and the PSK-style
  no-cert path; certificate validation is wired through a CertificateValidator
  SPI (real impl lands in Phase L).
- Transport parameters codec covering all RFC 9000 §18.2 + RFC 9221 fields.
- QuicWriter/QuicReader buffer helpers shared across the rest of the stack.

Round-trip test: a minimal in-process TLS server built from the same primitives
drives a full ClientHello → ServerHello → EE → Finished → client Finished
exchange. Both sides reach handshake-complete and agree bit-for-bit on the
handshake & application traffic secrets. ALPN + transport parameters round-trip
through EncryptedExtensions cleanly.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 17:33:54 +00:00
Claude 2d541c6fd4 feat(quic): Phase A — module foundations
Create the new :quic Gradle module (KMP, api(project(":quartz"))) and migrate
the QUIC varint codec out of :nestsClient where it was incidentally living.
Add the connection-ID, packet-number-space, and UDP socket primitives that
the rest of the QUIC client will build on.

Layer-by-layer plan in docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md.

- New :quic module wired into settings.gradle, with commonMain + jvmAndroid
  source sets mirroring :quartz's structure.
- Varint moves from com.vitorpamplona.nestsclient.moq to com.vitorpamplona.quic;
  MoqBuffer/MoqCodec updated to import the new path.
- ConnectionId enforces the 0..20 byte length range and ships a randomizer
  backed by Quartz's RandomInstance.
- PacketNumberSpaceState tracks per-space outbound allocation + largest-received
  tracking, and implements the RFC 9000 §A.3 truncated-PN decode formula plus
  the §17.1 minimum encode-length picker.
- UdpSocket is an expect class with a connected DatagramChannel actual on
  jvmAndroid using Dispatchers.IO (no Selector — one socket per connection).

All 12 tests pass on jvmTest. RFC 9000 §A.1 varint vectors and §A.3 truncated-PN
vector match bit-for-bit.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 17:19:02 +00:00