9bbfe718f907fed875bdc9f2b6bb4e9f9f25e5dd
71 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f63e3b1c67 | Merge branch 'main' of https://github.com/vitorpamplona/amethyst | ||
|
|
df98235d31 | Minor adjustments to remove warnings | ||
|
|
a84fbd2e57 |
test(quic): add concurrent producer/consumer regression for SendBuffer
The previous SendBuffer suite (FlowControlEnforcementTest) is entirely
single-threaded — every test calls enqueue and takeChunk sequentially
on the same coroutine, so the race that crashed the audio path in
production (NoSuchElementException from chunks.first() under
concurrent enqueue + takeChunk) stayed invisible. The whole :quic
commonTest tree had no concurrent test at all.
Three new tests run real-thread races on Dispatchers.Default:
- concurrent_enqueue_and_takeChunk_does_not_throw drives multiple
producer coroutines + a consumer coroutine and asserts the buffer
drains cleanly with no exception.
- concurrent_takeChunk_callers_never_double_drain_a_chunk fans out
multiple consumers against a pre-populated buffer; asserts the
sum of bytes handed out equals the bytes enqueued (i.e. no chunk
is double-counted by overlapping head-peel paths).
- concurrent_finish_with_inflight_enqueue_emits_correct_fin races
finish() against in-flight writes and asserts the FIN comes
AFTER every enqueued byte.
Tests pass against the synchronised SendBuffer; running them against
the pre-fix unsynchronised version corrupts state badly enough that
the consumer wedges (an explicit "this is what the bug looked like"
demonstration). With internal synchronisation in place the suite
finishes in <0.2 s.
Documents the concurrent-access contract so a future "let's drop the
sync, it's hot" refactor immediately fails CI.
|
||
|
|
7f05fd6e2a |
fix(quic): round-5 audit fixes — concurrency, ack-eliciting flags, scope leaks
Two parallel audit agents inspected the round-4 commits for regressions and
concurrency hazards. Major findings:
ackEliciting regression (HIGH from core-regression report):
Round-4's ACK gating optimization (only emit ACKs when something
ack-eliciting was received) didn't update the parser's per-frame
handling. MaxDataFrame, MaxStreamDataFrame, MaxStreamsFrame,
NewConnectionIdFrame, HandshakeDoneFrame, ResetStreamFrame, StopSendingFrame,
NewTokenFrame all need ackEliciting=true per RFC 9000 §13.2.1. Pre-fix a
packet carrying only one of these would record the PN but never trigger
an ACK, causing the peer to PTO-retransmit forever.
HandshakeDoneFrame conditional (HIGH):
Pre-fix the dispatcher unconditionally set status=CONNECTED; if
applyPeerTransportParameters had just called markClosedExternally
(e.g. CID-validation failure), a later HANDSHAKE_DONE in the same
payload would resurrect the connection. Now only sets CONNECTED when
status is HANDSHAKING.
WT scope leak (CRITICAL from concurrency report):
QuicWebTransportSessionState.close() never cancelled the scope holding
the demux pump and capsule reader coroutines; both kept running past
close, retaining QuicStream / chunk channels indefinitely. Memory
growth on long sessions that opened/closed many WT sessions.
WtPeerStreamDemux.route() collector leak (CRITICAL):
The route function launches a coroutine to drain stream.incoming into
chunkChannel (UNLIMITED). Four early-return paths (truncated stream
type, mismatched WT signal, foreign session id) returned without
closing chunkChannel — collector kept running, channel grew unbounded.
Now wrapped in coroutineScope{} so the collector is joined on every
exit. Also explicitly cancels collector on the catch path.
RESET_STREAM stream-id ownership (HIGH):
Pre-fix the dispatcher closed the local read side on whatever stream
the peer named. RFC 9000 §3.5: the peer can only RESET_STREAM streams
where it owns a send side. A peer RESETting a CLIENT_UNI is
STREAM_STATE_ERROR (we own the only side). Now closes the connection
in that case.
looksLikeIpLiteral tightening (HIGH):
Pre-fix accepted "1.2.3.4.5", "1.2", "1." as IP literals — Java's
InetAddress.getByName resolves all of those via DNS, defeating
audit-4 #4's SNI-leak fix. Now strict: 4 dot-separated octets each
in 0..255, or contains a colon (IPv6).
Signal channels closed on teardown (MEDIUM):
closeAllSignals() helper closes peerStreamSignal +
incomingDatagramSignal alongside closedSignal; pre-fix only closedSignal
was closed and racing parser frames could still trySend into
never-consumed channels. Centralised the call so close() and
markClosedExternally both invoke it.
Driver.close() idempotency (HIGH):
A second concurrent close() (common: session close + read-loop death
racing) used to launch a parallel teardown that called scope.cancel()
while the first's joinAll was mid-flight. Now memoizes the launched
Job behind a synchronized block.
Driver.close() flush detection (MEDIUM):
Pre-fix spun on `pendingDatagrams.isEmpty()` to detect
CONNECTION_CLOSE flush, but the writer's CLOSING branch bypasses
pendingDatagrams entirely. Now spins on `connection.status ==
CLOSING`, which transitions to CLOSED only after drainOutbound builds
the close packet.
@Volatile on peerMaxStreams* (MEDIUM):
peerMaxStreamsBidi/Uni snapshots are documented lock-free; without
@Volatile, JLS allows long-tearing on 32-bit JVMs and the JIT may
cache stale values.
CertificateFactory parse inside try (MEDIUM):
Malformed cert chain bytes used to throw raw CertificateException
through the read loop. Now wrapped, so parse failure becomes a clean
CONNECTION_CLOSE.
GOAWAY id-regression observability (MEDIUM):
Pre-fix the QuicCodecException thrown on increasing GOAWAY id was
silently swallowed by route()'s catch. Now also surfaces via
peerGoawayProtocolError so the application/QUIC layer can act.
appendFlowControlUpdates uses streamsListLocked (perf):
The round-4 perf #10 fix introduced streamsListLocked (no
entries.toList per drain) but appendFlowControlUpdates still iterated
the Map. Now also uses the index-friendly view.
peerCloseDeferred completion on session close (MEDIUM):
awaitPeerClose() used to hang forever if the local side called close()
before any peer-initiated WT_CLOSE_SESSION arrived. Now cancelled with
CancellationException on local close.
Tests:
AckElicitingFramesTest pins the ackEliciting contract on every round-5
fix plus the ResetStream-on-CLIENT_UNI rejection.
JdkCertificateValidatorIpLiteralTest pins the tightened pattern via
reflection.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
920b36cdd6 |
perf(quic): round-4 perf-audit fixes — ACK gating, flow-control dirty-set, streams list view
Four perf wins from the round-4 audit, all in the steady-state hot path (audio rooms run at ~50 datagrams/sec/participant). Perf #1 — ACK frame gating (saves a frame + bandwidth on every drain): AckTracker.buildAckFrame now returns null when no new ack-eliciting packet has arrived since the last build. Pre-fix the writer emitted a redundant ACK frame on every outbound packet (~50/sec each direction) even when the only inbound traffic since last drain was ACK-only. RFC 9000 §13.2 only requires ACKs in response to ack-eliciting packets within max_ack_delay; gating on ackElicitingPending satisfies that without delay-timer machinery. Perf #11 — AckTracker.purgeBelow short-circuit: Common case: peer ACKs a high PN, we already pruned below it. Pre-fix triggered a full ListIterator walk anyway. Now bails out when the tail's start is already above the threshold. Perf #9 — Flow-control dirty-set: QuicStream gains a receiveDirtyForFlowControl flag set by the parser when readContiguous advances the frontier. The writer's appendFlowControlUpdates now skips per-direction-window lookup + threshold comparison for streams whose flag is unset. Big win for multi-stream sessions (audio rooms with N×M streams per participant). Perf #10 — Streams list view: QuicConnection maintains a parallel insertion-ordered List<QuicStream> alongside the streams Map. Writer's round-robin scan reads the list directly instead of allocating `entries.toList()` per drain. No removal path exists today; the insert points (openBidi/UniStream, getOrCreatePeerStreamLocked) update both. AckTrackerGatingTest pins the new gating contract: first build returns a frame; second build without new reception returns null; subsequent ack-eliciting reception re-arms; non-ack-eliciting receptions alone don't. https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx |
||
|
|
21da61ad64 |
test(quic): comprehensive regression tests for round-4 fixes + coverage holes
Pins every behavioural change made in the round-4 audit-fix commit so a
future regression can't quietly resurrect any of the bugs.
FrameRoutingTest (new):
* RESET_STREAM / STOP_SENDING / NEW_TOKEN round-trip and don't kill
the connection on arrival
* Peer attempting CLIENT_BIDI / CLIENT_UNI stream IDs closes the
connection (STREAM_STATE_ERROR)
* MaxDataFrame raises sendConnectionFlowCredit; lower values ignored
* CONNECTION_CLOSE returns immediately — frames after it are not
dispatched (would otherwise create phantom streams on a closed
connection)
* HANDSHAKE_DONE at APPLICATION level is legal (ensures the level-
validation guard didn't over-fire)
* incomingDatagrams queue caps at MAX_INCOMING_DATAGRAM_QUEUE; oldest
entries dropped on overflow
ReceiveBufferFinTest (new): the audit-4 #4 silent-truncation fix
* isFullyRead() stays false when FIN arrives before a gap fills
* isFullyRead() flips true only after contiguous-end reaches finOffset
* Zero-length FIN frame at exact end marks stream complete
* finOffset is pinned at first observation (RFC 9000 §4.5)
QuicConnectionWriterTest (new): drainOutbound paths previously untested
* CLOSING-status drain produces a CONNECTION_CLOSE packet
* appendFlowControlUpdates raises stream.receiveLimit after consumer
drains > half window
* Writer enforces sendConnectionFlowCredit cap (audit-4 #9 — never
exceeds the peer's initial_max_data even with more bytes queued)
JcaAesGcmAeadTest (new, jvmTest): JVM-platform AEAD round-trip
* seal → open round-trip
* Different nonces produce different ciphertexts
* Rebuild path (same nonce twice) uses fallback cipher and still opens
* Corrupted ciphertext / wrong AAD → null
* key/nonce/tag length constants
ChaCha20Poly1305AeadTest (new): the seal side that prior tests skipped
* seal → open round-trip
* Bad tag / wrong AAD → null
* Wrong-size key/nonce throws IllegalArgumentException
WtPeerStreamDemuxTest: GOAWAY id-regression branch (audit-4 #5)
* Increasing GOAWAY id is rejected; previously recorded id stays put
CapsuleReaderTest: new strictness assertions
* WT_CLOSE_SESSION body < 4 bytes throws QuicCodecException
* Reason > 8192 bytes throws QuicCodecException
Notes:
* QuicConnectionDriver direct unit tests would require turning UdpSocket
from `expect class` into an interface; deferred — driver paths are
exercised end-to-end by InteropRunner and indirectly via every pipe-
based test.
* Decrypting client-emitted packets in tests requires server-side keys
(server's RX = client's TX with different cached cipher state in JCA);
writer tests assert side-effects on connection state instead.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
222a4e7d42 |
fix(quic): round-4 tier-1 + tier-2 audit fixes
Critical interop blockers + security/correctness gaps surfaced by the
parallel round-4 audit. All fixes have inline comments referencing the
audit finding number.
Frame layer:
* Decode RESET_STREAM (0x04), STOP_SENDING (0x05), NEW_TOKEN (0x07).
Pre-fix these fell through to the `unknown frame type` branch and
threw QuicCodecException through the read loop, killing the
connection. aioquic and picoquic emit RESET_STREAM regularly.
* Wrap decodeFrames in try/catch in dispatchFrames; on a decode
error, transition to CLOSED gracefully via markClosedExternally
instead of letting the exception escape the read loop.
Connection layer:
* Reject peer-attempted CLIENT_BIDI / CLIENT_UNI stream IDs that don't
map to a stream we opened (RFC 9000 §19.8 STREAM_STATE_ERROR).
* MaxDataFrame now actually updates sendConnectionFlowCredit (was a
no-op pre-fix; sustained sends silently stalled).
* Writer enforces sendConnectionFlowCredit and tracks
sendConnectionFlowConsumed so cumulative bytes stay under the
peer's initial_max_data cap.
* SERVER_BIDI peer-opened streams inherit sendCredit from
peer.initialMaxStreamDataBidiLocal (was 0L; reply path was wedged
until MAX_STREAM_DATA arrived).
* applyPeerTransportParameters validates initial_source_connection_id
and original_destination_connection_id (RFC 9000 §7.3 MUST checks);
mismatch closes with TRANSPORT_PARAMETER_ERROR.
* Cap incomingDatagrams queue at 256 (audio rooms ~50/sec; 5-second
burst). On overflow, drop oldest — fresh frames matter more for
live media. Pre-fix RFC 9221 datagrams were unbounded.
Stream layer:
* QuicStream.deliverIncoming now returns Boolean; parser closes the
connection with INTERNAL_ERROR on saturation rather than silently
dropping bytes (peer believes the bytes were delivered, application
sees a hole).
* ReceiveBuffer tracks finOffset and exposes isFullyRead(); parser
only closes the incoming channel after the contiguous read frontier
reaches the FIN offset (pre-fix closing on FIN-frame arrival
truncated streams that had gaps).
TLS hardening:
* certificateValidator is non-null. Tests pass an explicit
PermissiveCertificateValidator; null was a silent-MITM hazard.
* Drop SIG_RSA_PKCS1_SHA256 from accepted CertificateVerify
schemes (forbidden by RFC 8446 §4.2.3 in CertificateVerify).
* Hard-fail the PSK-Finished path: we never offer a pre_shared_key
extension, so a server skipping Certificate/CertificateVerify is
either misbehaving or a partial-MITM stripping cert proof.
* Validate ALPN: reject any ALPN the server selected that we didn't
offer (was previously accepted silently).
* Add APPLICATION-level inboundBuffer so post-handshake CRYPTO
(NewSessionTicket, KeyUpdate detection) reaches the
SENT_CLIENT_FINISHED handler.
* State.FAILED is now actually assigned on any handler throw;
pushHandshakeBytes refuses further bytes when in FAILED.
* IP-literal precheck before InetAddress.getByName so cert
validation doesn't trigger DNS A/AAAA lookups for hostnames
(audit-4 #4: leaked SNI/hostname over plaintext DNS).
WT layer:
* GOAWAY id-regression check (RFC 9114 §5.2: MUST NOT increase).
A server sending an increasing id raises QuicCodecException.
* WT_CLOSE_SESSION decoder rejects bodies < 4 bytes (mandatory
error-code field) and reasons > 8192 bytes.
* Capsule reader catches Throwable but separately rethrows
CancellationException; on parse error, completes peerCloseDeferred
exceptionally so awaitPeerClose() doesn't hang forever.
HTTP/3 + QPACK:
* Http3Settings.decodeBody rejects duplicate ids (RFC 9114 §7.2.4.1
H3_SETTINGS_ERROR).
* QpackInteger.decode bounds-checks shift before extending value;
defence-in-depth Long-overflow check on accumulated value.
* QpackDecoder static-table accesses go through a bounds-checking
helper that throws typed QuicCodecException; literal lengths are
range-checked before allocation.
Test infra:
* InMemoryQuicPipe accepts an injectable serverScid and constructs
its tlsServer with TPs that include the required CIDs.
* InProcessTlsServer emits stub Certificate + CertificateVerify
so the real (non-PSK) handshake path is exercised.
* Updated all test callers to use PermissiveCertificateValidator.
* Updated CapsuleReaderTest with negative-path assertions for the
new strictness.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
0023c73aeb |
test(quic): regression tests for receive-limit, incoming channel cap, coalesced-packet skip
Three pending audit-2/3 regression tests, plus the InMemoryQuicPipe helpers needed to drive them. ReceiveLimitEnforcementTest — peer overshooting per-stream receive limit must transition the connection to CLOSED via markClosedExternally. Mirror test verifies the boundary value (frameEnd == receiveLimit) does NOT close. QuicStreamIncomingChannelTest — the per-stream incoming channel is bounded at 64 chunks; trySend on saturation must not block (would deadlock the parser on the connection lock). Empty chunks are filtered. closeIncoming terminates the collector. CoalescedPacketSkipTest — RFC 9000 §12.2 / RFC 9001 §5.5: feedDatagram must walk across coalesced packets, must skip a packet that fails AEAD verification using peekHeader.totalLength (not break the loop), and must exit cleanly when a trailing header is truncated. Pipe additions: buildServerApplicationDatagram + coalesceDatagrams give tests the primitives to drive arbitrary server → client app-level frames. InMemoryQuicPipe also takes an optional tlsServer so tests can advertise non-default transport parameters. https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx |
||
|
|
62a42cb36d |
fix(quic): audit-3 follow-ups + regression coverage
Cipher caching: cache JCA Cipher + SecretKeySpec per direction in a new
JcaAesGcmAead so steady-state seal/open avoids Cipher.getInstance("AES/GCM/
NoPadding") per packet (audit-1, audit-3 hot path). Initial-padding rebuild
edge case (re-encrypting the same PN with the same nonce) falls back to a
fresh cipher because JCA tracks (key, iv) pairs and rejects legitimate reuse.
Channel-based wakeups: replace the WT peer-stream poller's delay(5)
busy-loop with awaitIncomingPeerStream/awaitIncomingDatagram suspending
on conflated wakeup channels fired by the parser. Connection close also
closes a closedSignal so any awaiter unblocks promptly with null.
Driver close ordering: close() now joins the read + send loops with a
bounded timeout instead of yield()+cancel()-racing them. Catches the case
where scope.cancel() fired mid-socket.send, occasionally producing partial
datagrams or skipping CONNECTION_CLOSE entirely.
WT graceful close: spawn a CapsuleReader-driven coroutine on the CONNECT
bidi that decodes WT_CLOSE_SESSION and surfaces it via peerCloseSession +
awaitPeerClose. Previously the encoder existed but no decoder consumed
incoming capsules, so peer-initiated graceful close was silent.
GOAWAY: WtPeerStreamDemux decodes the GOAWAY varint body into
peerGoawayStreamId instead of `is Goaway -> Unit`-dropping it.
TLS transcript hash: incremental SHA-256 backed by JCA MessageDigest,
snapshotted via clone() — replaces the O(n²) "concatenate-everything-and-
re-hash on every snapshot" implementation. TLS 1.3 takes ≥3 snapshots per
handshake.
MAX_STREAMS routing: parser now bumps peerMaxStreamsBidi/Uni on inbound
MAX_STREAMS frames; openBidiStream/openUniStream throw QuicStreamLimitException
when the cap is reached instead of silently overrunning it. Initial cap
sourced from peer transport parameters.
Regression tests:
* CapsuleReaderTest – round-trip, split-chunk, partial, unknown types
* TlsTranscriptHashTest – snapshot determinism, no consume-on-snapshot
* PeerStreamLimitTest – TP-driven cap, MAX_STREAMS frame round-trip
* WtPeerStreamDemuxTest – CONTROL stream GOAWAY decode
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
02b03e143b |
test(quic): in-memory QUIC pipe (quiche-style) for full-stack handshake
Adds InMemoryQuicPipe — a quiche-Pipe-style harness that runs a real QuicConnection through the full handshake without touching the network. The "server" side wraps InProcessTlsServer in QUIC packet protection (Initial + Handshake long-header packets) and routes CRYPTO bytes between the layers. Direct port of the pattern from quiche/src/test_utils.rs (`Pipe`). InMemoryQuicPipeTest.client_connection_reaches_connected_via_in_memory_pipe verifies the full client receive path: - ClientHello at Initial level → server decrypts, drives TLS, replies - Server Initial packet (ServerHello) → client decrypts, derives handshake keys - Server Handshake packets (EE + Finished) → client verifies, derives 1-RTT keys - Client Finished at Handshake level → server verifies - Client status flips to CONNECTED, both directions of 1-RTT keys installed This is the test category three of the four mature QUIC implementations surveyed have or rely on: - quiche's `Pipe` is the gold standard (we ported it here) - quic-interop-runner is the network-level equivalent (Docker matrix) - kwik notably does NOT have one — uses Mockito + reflection instead It catches the largest class of bugs: wrong layer-to-layer wiring (e.g. TLS layer derives keys but QUIC layer doesn't install them, the hardcoded-cipher-suite C1 bug, AckTracker PN bug C2 across coalesced packets). Future tests can build on it: stream send/receive, datagram round-trip, flow-control stall, retransmission once we add it. Pipe currently supports AES-128-GCM only; ChaCha20 path validation is covered by TlsRoundTripTest at the TLS layer for now. https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx |
||
|
|
cd68502355 |
test(quic): adversarial + parametrized + negative-path tests from review
Builds the test categories the audit identified as missing. Patterns informed
by surveys of Cloudflare quiche (the `Pipe` style + flow-control assertions),
kwik (server-side hostile-peer matrix), and quic-interop-runner (scenario
checklist).
FrameFuzzerTest — 8 tests
- 2000 random byte sequences fed through decodeFrames; the contract is
"succeed or throw QuicCodecException, never crash." Catches the C5 class
(oversized varints) plus general DoS resilience.
- Crafted hostile vectors: STREAM with length=2^62-1, CRYPTO 1 GiB,
ACK with 1B range count, NCID with cidLen=255, CONNECTION_CLOSE with
1 GiB reason, DATAGRAM 1 GiB, valid frame followed by unknown type.
AckTrackerCoalescedTest — 3 tests
- Two coalesced packets in one datagram both end up in the ACK frame.
Direct regression test for the C2 bug where the parser fed
`state.pnSpace.largestReceived` instead of the actual decrypted PN.
- Gapped PNs produce two ranges with the correct gap encoding (RFC 9000
§19.3.1 `previous_smallest - current_largest - 2`).
- Out-of-order arrival of contiguous PNs still merges into one range.
FlowControlEnforcementTest — 6 tests
- SendBuffer respects maxBytes (the writer's `sendCredit - sentOffset`
enforcement point).
- maxBytes=0 with pending data returns null (sender stalls cleanly).
- Multi-take across chunked-queue boundaries preserves byte order
(regression coverage for the new O(1) chunked enqueue replacing the
old O(N²) copyOf path).
- FIN handling: piggyback on final data chunk vs. zero-length post-data.
TlsSecurityPropertiesTest — 5 tests
- ServerHello with non-empty session_id_echo rejected (RFC 8446 §4.1.3
downgrade signal).
- Pre-TLS-1.3 legacy_version rejected.
- Server picking unsupported group (secp256r1) rejected — we advertise
X25519 only.
- Missing supported_versions / missing key_share extensions rejected.
TlsRoundTripTest — multi-cipher parametrization
- InProcessTlsServer takes a `preferredCiphers` list.
- New test forces ChaCha20-Poly1305-SHA256 selection and asserts the
full handshake completes with that suite, with the
onApplicationKeysReady callback reporting the actual negotiated
cipher (not the previously-hardcoded AES). This is direct regression
coverage for the C1 bug.
Total: 22 new tests + multi-cipher parametrization. All :quic:jvmTest +
:nestsClient:jvmTest pass.
Notable gap acknowledged from surveys: an in-memory `Pipe`-style
client+server harness (quiche pattern). Requires a server-side
QuicConnection implementation, which is ~1 day of work; deferred until we
have a concrete need beyond what the in-process TLS server already covers.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
368b8dd432 |
fix(quic): C3+C4+C9 + Tier-2 robustness from review
C3+C4 — HTTP/3 frame reader + WebTransport response :status check
New Http3FrameReader buffers stream bytes and yields complete frames
(DATA, HEADERS, SETTINGS, GOAWAY, Unknown). QuicWebTransportFactory
drains the request stream after sending the Extended CONNECT request,
feeds bytes through the reader, decodes the first HEADERS frame via
QPACK, and pulls `:status`. Non-2xx → ConnectRejected. Without this,
any 401/404/500 yielded a "connected" session that silently dropped.
Tests: 5 new H3FrameReader tests covering SETTINGS, HEADERS round-trip,
cross-push reassembly, unknown-type passthrough, multi-frame in one push.
C9 — flow-control enforcement + receive-side crediting
- Send: per-stream `sendCredit` is now consulted before each takeChunk;
bytes beyond `sendCredit - sentOffset` are held back. SendBuffer
exposes `sentOffset` for the writer.
- Receive: appendFlowControlUpdates() emits MAX_STREAM_DATA when the
receive cursor crosses half the advertised window, and MAX_DATA at
the connection level. Without this, peer windows close and any
sustained transfer wedges silently.
Tier-2 cleanups (six items in one batch):
- AckTracker.purgeBelow(): drop ranges below peer's largest_acked when
we receive an ACK frame. Range list no longer grows unboundedly on
long connections.
- ReceiveBuffer adjacency edge: pull in the prior chunk when its
endOffset exactly equals the new chunk's start. Previously perfectly-
sequential receives starting at offset > 0 left adjacent chunks
unmerged, growing the chunk list and overcounting bufferedAhead.
- RetryPacket integrity-tag verify: constant-time compare instead of
contentEquals.
- ServerHello legacy_session_id_echo MUST be empty per RFC 8446 §4.1.3
(we send empty); reject non-empty as a downgrade signal.
- TLS state machine handles post-handshake NewSessionTicket and
KeyUpdate at Application level — silently drop instead of throwing
"unexpected post-handshake type" and tearing down the connection.
Tier-3 perf: SendBuffer chunked queue
Replaced the O(N) copyOf-on-every-enqueue with an ArrayDeque<ByteArray>
+ headOffset cursor. Enqueue is now O(1); takeChunk peels at most one
head chunk. Memory is bounded by the sum of outstanding writes instead
of (sum)². For sustained MoQ stream writes of small chunks this drops
from O(N²) memcpy to O(N).
All :quic:jvmTest + :nestsClient:jvmTest pass — every RFC 9001 Appendix A
vector still verifies bit-for-bit.
Remaining items deferred:
Tier-2: incremental transcript hash (low impact: 4-5 calls per handshake
over <10 KB), TLS HelloRetryRequest detection (we never send
incompatible ClientHello today, server won't HRR).
Tier-3: cipher reuse, Huffman lookup tree, UdpSocket selector, packet
codec triple-allocation. None block live interop; revisit if
measured RTT or CPU surfaces them.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|