ec92a82e4e5cd614cc59aef2174366d929498d8c
234 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ed1f10c071 |
fix(quic): omit IP-literal SNI, bound the handshake, fix dev harness host
Three findings from the local nests interop hang (relay logged "Illegal SNI extension: ignoring IP address presented as hostname"): - TlsClientHello sent the connect host verbatim as TLS SNI, including IP literals. RFC 6066 §3 forbids IP literals in server_name; a strict relay (rustls) discards the extension, ends up with no SNI to select its certificate on, and the handshake stalls. Both ClientHello builders now omit server_name when the host is an IP literal (new isIpLiteralHostname predicate). - QuicWebTransportFactory.connect never bounded awaitHandshake() — only readConnectResponse had a timeout — so a stalled handshake hung connect() indefinitely. It now fails fast after connectTimeoutMillis with a message naming the likely cause. - NostrNestsHarness pointed moqEndpoint at 127.0.0.1, which forced the IP-literal SNI path against the dev relay. Switched to localhost: a valid hostname that matches the relay's generated dev cert and still resolves to loopback. https://claude.ai/code/session_01PoQupfdoKU3ryQwLwTeXeM |
||
|
|
8a3bd52631 |
fix(quic): finish P1 + A2 audit follow-ups (PR #2873)
P1 (header protection — eliminate remaining HP allocations):
Add HeaderProtection.maskInto(hpKey, src, srcOffset, scratch16, dstMask)
that writes the 5-byte mask into a caller-owned buffer using a
caller-owned 16-byte AES scratch. Add `hpScratch` (16 bytes) and
`hpMask` (5 bytes) to PacketProtection — same per-direction
single-threaded analysis as nonceScratch from the prior commit. Thread
both buffers through Short/LongHeaderPacket build / parseAndDecrypt /
peekKeyPhase as optional parameters paired with nonceScratch; production
call sites in QuicConnectionWriter / QuicConnectionParser pass
`proto.hpScratch` + `proto.hpMask`. With both this commit and
|
||
|
|
09b28b8d78 |
fix(quic): address self-audit findings on #2861 fixes (PR #2873)
Addresses three issues found in the self-audit of commit
|
||
|
|
fbacef1f2a |
fix(quic): address #2861 code-review findings
Implements the 10 actionable items from the QUIC code review in issue #2861; the lone P4 item (encrypt-under-streamsLock) is already tracked as deferred phase 2 work in quic/plans/2026-05-08-lock-split-design.md and is unchanged. Android-only correctness (would silently break on API 26–32): - A1 JdkCertificateValidator: wrap Signature.getInstance("Ed25519") in try/catch and surface NoSuchAlgorithmException as a clean QuicCodecException so an Ed25519 leaf cert no longer crashes the TLS read loop on pre-API-33 Android. - A2 TlsRunningSha256 (jvmAndroid): keep a parallel byte accumulator and fall back to one-shot SHA-256 on the rare API-26–28 Conscrypt builds whose OpenSSLMessageDigestJDK throws CloneNotSupportedException from MessageDigest.clone(). Latches the fallback after first failure so we don't pay the JCA throw per snapshot. Hot-path performance: - P1 HeaderProtection: add maskAt(hpKey, src, srcOffset) + extend AesOneBlockEncrypt with encryptInto(key, src, srcOffset, dst, dstOffset) so the per-packet HP path no longer allocates a 16-byte sample slice AND no longer allocates a 16-byte JCA Cipher output (the jvmAndroid impl uses Cipher.doFinal's range overload). Updated 5 call sites in ShortHeaderPacket and LongHeaderPacket. - P2 aeadNonce: add aeadNonceInto(staticIv, packetNumber, dst) so call sites with a persistent 12-byte scratch can build the nonce without per-packet allocation; aeadNonce keeps its existing shape via the new helper. Threading the scratch through Short/LongHeaderPacket and the writer is deferred (similar shape to the documented P4 phase 2 work). - P3 QuicConnectionParser: decode the frame list once per inbound packet, feed both qlog (frameNamesFor) and dispatch from the single decode. Pre-fix every qlog-attached packet ran decodeFrames twice. - P5 QuicConnectionWriter: iterate pendingMaxStreamData / pendingNewConnectionId directly instead of allocating entries.toList() per drain. Protocol / security: - S1 QuicConnectionParser: cap MAX_STREAMS at 2^60 (RFC 9000 §19.11); a peer sending a larger value now triggers STREAM_LIMIT_ERROR close rather than overflowing the local nextLocalBidi/UniIndex counters. - S2 QuicConnection.effectiveResumption: drop the cached session ticket when (now - issuedAt) ≥ min(ticketLifetimeSec, 7 days) per RFC 8446 §4.6.1; expired tickets would otherwise silently fail server-side and lose any 0-RTT bytes. - S3 QuicConnection: refuse to offer 0-RTT when our current alpnList doesn't include the resumed session's negotiated ALPN, and treat 0-RTT as rejected on EE if the new ALPN differs from the cached one (RFC 9001 §4.6.1). - S4 TlsExtension.encodeSignatureAlgorithms: drop rsa_pkcs1_sha256. The validator already rejects it in CertificateVerify per RFC 8446 §4.2.3 — advertising it lied about what we accept. All quic JVM unit tests pass; spotless applied. https://claude.ai/code/session_01EBHtGLy5o7FUR5qfcpHUUx |
||
|
|
1d503dc6ac |
Adds android.util.Log mock for quic tests
Mirrors the quartz commonTest stub so :quic:testAndroidHostTest no longer
throws RuntimeException("Stub!") through PlatformLog.android on the
MAX_STREAMS_UNI emission path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
5c39e3c85b | Removes more warnings | ||
|
|
1ff4f98fff |
docs(quic-interop): add quinn to the default peer set
quinn is now treated as a flawless-required interop target alongside aioquic, picoquic, and quic-go. Most Rust-based Nostr/MoQ relays our users run their servers on are built on quinn, so an interop regression there is a user-visible regression. Validated by a 3-round flakiness sweep (1 full matrix + 2 audio-critical subsets) — zero result flakiness across 528 test executions across all four peers, with two environmental docker-compose stall classes documented separately. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
0bf0077aa5 |
fix(quic): emit ACK_ECN with all-zero counts (RFC 9000 §13.4.2)
Upstream commit
|
||
|
|
0920aba2b8 |
fix(quic): issue spare source CIDs to peer post-handshake-confirmed
RFC 9000 §5.1.1 + §19.15: a client SHOULD advertise spare source
CIDs to the peer via NEW_CONNECTION_ID frames so the peer has
DCIDs available for path migration / NAT rebind. Strict server
stacks (quic-go, picoquic, msquic, mvfst) refuse to validate a new
client path until they have a spare DCID — server log shows
"skipping validation of new path … since no connection ID is
available". Quinn migrates on src-port-change alone, which is why
rebind-port / rebind-addr passed against quinn pre-fix and failed
against the others.
Adds:
- IssuedSourceConnectionIdEntry (data class) — sequence + CID +
stateless-reset token tuple.
- QuicConnection.issuedSourceConnectionIds (LinkedHashMap) — pool
of currently-active issued SCIDs, seeded with seq=0 (the
initial CID, no token because the stateless_reset_token TP is
server-only per RFC 9000 §18.2).
- QuicConnection.issueOwnConnectionIdsLocked(count) — drives the
initial issuance once handshake is confirmed; appends recovery
tokens to pendingOwnNewConnectionIdEmits for the writer to emit.
- QuicConnection.issueOwnReplacementSourceCidLocked() — top-up
after each peer RETIRE_CONNECTION_ID so the pool stays at
full capacity for repeated migrations.
- QuicConnectionWriter — once handshakeConfirmed flips, calls
issueOwnConnectionIdsLocked with `peer.activeConnectionIdLimit
- 1` (cap 7); drains pendingOwnNewConnectionIdEmits as fresh
NEW_CONNECTION_ID frames; the existing pendingNewConnectionId
map continues to handle loss-recovery retransmits.
- applyPeerRetireConnectionIdLocked — three cases: in-pool seq
is freed and replaced; below-next-seq missing seq is benign
retransmit; above-next-seq is PROTOCOL_VIOLATION (peer
retiring something we never advertised).
Verified against the live interop runner:
- quic-go rebind-port: 2/2 (was 0/N — Task 2). Now passes in ~64s.
- quic-go rebind-addr: 2/2 (was 0/N). Now passes in ~125-138s.
- picoquic rebind-port: 2/2.
- picoquic rebind-addr: 1/2 (flaky — separate investigation;
one run the connection migrates correctly, the other times
out at 110s; not blocking the rebind-port / quic-go win).
- picoquic connectionmigration: 0/2 still failing (the runner's
"active migration" testcase exercises a more sophisticated
migration shape — TBD; tracked separately).
Tests: IssuedSourceConnectionIdTest (5 cases pinning the
post-handshake emission, peer-RETIRE replacement, retransmit
tolerance, and protocol-violation closure for above-next-seq retires).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
3a5c010993 |
fix(quic): consecutivePtoCount must advance once per PTO firing
RFC 9002 §6.2.2 says the consecutive-PTO count goes up by exactly
ONE per PTO timer expiry, so the §6.2.1 backoff is `pto_base * 2^count`
per firing. Pre-fix, the count incremented THREE times per firing:
1. inside `handlePtoFired` itself,
2. inside `requeueInflightForProbe` (which `handlePtoFired` calls),
3. again from the send loop's between-probe re-requeue (RFC §6.2.4
two-packet probe budget calls `requeueInflightForProbe` a
second time).
That made the effective backoff `2^(3*N)` per firing — 1×, 8×, 64×
of pto_base. With pto_base ≈ 150 ms post-handshake, PTO #3 didn't
fire until ~11 s post-handshake, well past the 5 s amp-limited
idle timeout strict servers (quic-go) enforce.
Quic-go's `amplificationlimit` testcase exposed this. The sim
drops client packets 2–7. Our PTO #2 second probe is packet #8
— the first one that gets through. With correct 1× increment
per PTO, packet #8 reaches the server in ~900 ms; pre-fix it
arrived at ~12 s, after quic-go had already destroyed the
connection ("Amplification window limited" → "Destroying
connection: timeout: no recent network activity"). Same root
cause for `handshakeloss` flakiness against quic-go (multiconnect
under 30% loss can't recover within the 30 s per-iteration
budget when PTO ramps to 64× by iteration 3).
Fix:
- Move the count increment to the top of `handlePtoFired`,
before it calls `requeueInflightForProbe`. The threshold
check inside the latter (RFC 9000 §9 — trigger
PATH_PROBE_PTO_THRESHOLD migration) reads the
post-increment value, preserving the prior 2-PTO-firing
trigger semantics.
- Remove the count increment from `requeueInflightForProbe`.
The send loop's between-probe re-requeue stays a no-op for
the count (same PTO firing).
Tests: PtoCryptoRetransmitTest.consecutivePtoCountAdvancesByOnePerPtoFiringNotPerProbePacket
pins the contract (full handlePtoFired → drain → re-requeue →
drain → handlePtoFired sequence; asserts count after each step).
Verified end-to-end against the live interop runner:
- amplificationlimit vs quic-go: 5/5 (was 0/3 — consistent
30 s timeout). Now passes in ~7 s.
- handshakeloss vs quic-go: 5/5 (was 2/3 — flaky multiconnect
iteration timeouts). Now consistently ~30 s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
e8991fab69 |
fix(quic): gate client-initiated key update + path migration on HANDSHAKE_DONE
Pre-fix, [QuicConnection.initiateKeyUpdate] only checked that 1-RTT
keys were installed — which fires when the TLS handshake derives
Finished, well before HANDSHAKE_DONE arrives. RFC 9001 §6.1 + §4.1.2
require the CLIENT to consider the handshake confirmed (HANDSHAKE_DONE
received) before rolling KEY_PHASE; quinn / quic-go / picoquic
correctly close the connection with PROTOCOL_VIOLATION ("illegal
packet: key update error") when an early update arrives.
The interop runner's keyupdate testcase against quinn flushed this:
the client polled `status == CONNECTED` (which flips on TLS Finished
via `onHandshakeComplete`) and called `initiateKeyUpdate()` before
HANDSHAKE_DONE landed. ~33% repro rate; the rest of the runs HANDSHAKE_DONE
happened to arrive in the gap between the status check and the
update.
Fix:
- Add `QuicConnection.handshakeConfirmed: Boolean` flipped only by
the parser when a HANDSHAKE_DONE frame is processed.
- Add `awaitHandshakeConfirmed()` for callers that need the
spec-confirmed state.
- Gate `initiateKeyUpdate()` on `handshakeConfirmed` (returns
false otherwise — same shape as the existing app-keys-not-yet
branch).
- Tighten `triggerPathMigrationLocked` to also gate on
`handshakeConfirmed` (RFC 9000 §9.1: same confirmation
requirement). Pre-fix it gated on `handshakeComplete`, which
flips at TLS-Finished too.
- InteropClient's keyupdate flow now waits on
`awaitHandshakeConfirmed` (with a 2s upper bound) rather than
polling `status == CONNECTED`.
The test fixture in ConnectedClientFixture.newConnectedClient now
delivers HANDSHAKE_DONE by default — most tests want the
production "fully ready" state. New `deliverHandshakeDone = false`
parameter for tests that need to exercise the pre-confirmation
window (KeyUpdateClientInitiatedTest).
Tests:
- KeyUpdateClientInitiatedTest:
initiateKeyUpdateBeforeHandshakeDoneIsRejectedAndDoesNotRotateKeys
initiateKeyUpdateAfterHandshakeDoneRotatesBothDirections
awaitHandshakeConfirmedSuspendsUntilHandshakeDone
triggerPathMigrationBeforeHandshakeDoneReturnsNotConnected
Verified against the live interop runner — 5/5 against quinn,
3/3 against quic-go, 3/3 against picoquic (pre-fix: ~33% pass rate
against quinn).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
707fcee5b6 |
fix(quic): rotate DCID off failed CID on path validation timeout
Pre-fix, PathValidator.checkValidationTimeout queued a
RETIRE_CONNECTION_ID for the failed sequence number while leaving
QuicConnection.destinationConnectionId stamping that same CID. The
strict servers (quic-go / picoquic / msquic / mvfst) processed the
RETIRE, dropped their routing entry, then closed us with
PROTOCOL_VIOLATION the next time a packet arrived stamped with
the just-retired CID — visible in qlog as two RETIRE_CONNECTION_ID
frames within ~500 ms followed by "retired connection ID N, which
was used as the Destination Connection ID on this packet".
Reproducer: setting proactiveDcidRotationMillis=4_000L on
QuicConnection drove the trigger every 4 s; rebind-port vs quic-go
then failed at ~10 s with the close above. Reverted in this commit;
the experiment fields are gone.
The fix splits the timeout outcome into three cases:
- NotTimedOut — budget hasn't elapsed.
- RecoveredOnSpare — rotated active to the lowest spare CID;
queued RETIRE for the failed seq; the
QuicConnection wrapper synchronously
updates destinationConnectionId so the
next outbound stamps the new CID, atomic
under streamsLock with the validator
state change.
- StuckOnFailedCid — no spare available; KEEP the failed seq
active and DO NOT queue retire. The
writer keeps stamping the unvalidated
CID; if the path is genuinely dead the
connection idles out, and once a
NEW_CONNECTION_ID arrives the next
trigger rotates cleanly.
After the fix, rebind-port vs quic-go fails at the 60 s test
timeout with server-side trigger=idle_timeout (the Task 2 issue —
strict servers gate on fresh DCID at new src port, which we can't
synchronize with the sim's rebind cadence) instead of the
PROTOCOL_VIOLATION close.
Tests:
- PathValidatorTest:
validationTimeoutAfter3PtoTransitionsToFailedAndRetiresFailedCidWithSpare
timeoutWithoutSpareKeepsFailedSeqActiveAndDoesNotRetire
twoConsecutiveFailedValidationsRetireAllAbandonedSequencesWithSpare
- ClientPathMigrationTest:
backToBackSuccessfulMigrationsRetireExactlyOnePerRotationAndStampNewDcid
validationTimeoutWithSpareRotatesDcidAndRetiresFailedSeq
validationTimeoutWithoutSpareKeepsActiveCidAndDoesNotRetire
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
b2362f6504 |
refactor(nestsclient,quic): simplify-pass cleanups on moq-lite Lite-03/04 audit
Surface a few code-quality wins surfaced by the simplify pass on the
moq-lite Lite-03/04 audit. All semantic-preserving; tests untouched
in behavior.
- **Derive `MoqLiteSession.version` from `transport.negotiatedSubProtocol`**
instead of carrying it as a separate constructor parameter. The
version was already redundant with the transport's negotiated
ALPN; deriving it eliminates the parameter on
`MoqLiteSession.client(...)` and the `resolveMoqLiteVersion()` /
`moqVersion` plumbing in `connectNestsListener` /
`connectNestsSpeaker`. Tests drive the version via
`FakeWebTransport.pair(negotiatedSubProtocol = …)`, which already
plumbs through to the session's derivation.
- **Extract `MoqLiteSession.rejectSubscribe(bidi, errorCode, reason)`**
helper that writes a `SubscribeDrop` body and `RESET_STREAM`s the
bidi. Both Subscribe-rejection arms (`BROADCAST_DOES_NOT_EXIST`
and `TRACK_DOES_NOT_EXIST`) now share this helper instead of
duplicating the runCatching / write / reset shape. Future drop
codes plug in without growing the duplication.
- **Make `StrippedWtStream.stopSending` non-nullable.** The demux
always wires it (every surfaced stream has a read side), so the
`?:` "defensive" fallbacks in `StrippedWtReadStreamAdapter` and
`StrippedWtBidiStreamAdapter` were dead code. Tightens the
`StrippedWtStream` contract and shrinks the adapters.
- **Extract `SEQ_BITS=23` / `SEQ_MASK=0x007F_FFFF` / `SEQ_MAX`
constants** in `MoqLiteSession.openGroupStream`. The bit-pack
formula now reads as `(trackPriority and 0xFF) shl SEQ_BITS) or
(sequence and SEQ_MASK)` — layout is named, not derived from the
hex literals scattered through the body.
Items deferred (noted but not done):
- `handleInboundBidi` arm extraction — the per-arm variable capture
(`dispatched`, `inboundSub`, `inboundSubPublisher`,
`inboundAnnouncePublisher`) makes a clean refactor harder than
the readability win. Defer until a future feature changes the
dispatch shape and the refactor falls out naturally.
- `Fake{Bidi,Read}Stream` / `ChannelWriteStream` constructor
parameter explosion — the cells-as-nullable-named-params shape
is already readable; the candidate `FakeStreamSignals` struct
would force a left/right-direction flag on the bidi side, which
is uglier than the explicit `myReset` / `peerReset` naming.
- `MoqLiteCodec` `object` → versioned `class` — the
`version: MoqLiteVersion = LITE_03` parameter on each method is
the idiomatic Kotlin shape for an optional-with-default
discriminator; converting to a class would bind state to
instances and complicate the test path that exercises both
versions per-call.
- `announce()` / `probe()` pump abstraction — borderline; the
embedded logging + tracing in `announce()` would have to be
parameterized into the abstraction. Worth revisiting if a third
similar pump appears.
- `MoqLiteSubscribeDropCode` / `MoqLiteStreamCancelCode` sealed-
class refactor — `Long` constants match the wire shape; sealed
types would force conversion at every API boundary
(codec accepts `Long`, transport accepts `Long`).
https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
|
||
|
|
1c7b158e11 |
feat(quic,nestsclient): plumb RESET_STREAM + STOP_SENDING through WebTransport
Extends the WebTransport interface and its three implementations
(QUIC-backed, in-memory fake, peer-stripped demux) with typed
error-code cancel paths so moq-lite Lite-03's M2/M3 audit items can
land.
QUIC layer (`:quic`):
- `StrippedWtStream` gains optional `reset` and `stopSending`
closures, wired by `WtPeerStreamDemux.emitStripped` through
`QuicStream.resetStream(errorCode)` /
`QuicStream.stopSending(errorCode)` + `driver.wakeup()` so the
frames actually leave the connection. `reset` is null on
peer-initiated uni streams (no send half on our side);
`stopSending` is unconditional (every surfaced stream has a
read side).
WebTransport interface (`:nestsClient`):
- `WebTransportReadStream.stopSending(errorCode: Long)` —
suspending; first-call-wins.
- `WebTransportWriteStream.reset(errorCode: Long)` —
suspending; first-call-wins. Distinct from the existing
graceful `finish()` / FIN.
Adapters:
- `QuicBidiStreamAdapter`, `QuicReadStreamAdapter`,
`QuicUniWriteStreamAdapter` route directly to the underlying
`QuicStream` API.
- `StrippedWtReadStreamAdapter` /
`StrippedWtBidiStreamAdapter` route through the demux's
closures; defensive `?:` fallbacks defend against a future
demux bug that forgets to wire a closure.
- `FakeBidiStream`, `FakeReadStream`, `ChannelWriteStream` (the
in-memory test transport) record reset / stopSending codes
in shared `AtomicLong` cells so tests can assert "the peer
reset with code X" / "the listener stopSending with code Y"
via new `lastResetCode` / `lastStopSendingCode` /
`lastPeerResetCode` / `lastPeerStopSendingCode` properties.
Sentinel `NO_CODE = Long.MIN_VALUE` distinguishes "not called"
from a legitimate code 0.
Pure plumbing — no moq-lite call sites use the new methods yet.
Subsequent commits wire M3 (publisher Drop reply uses
reset(errorCode)) and M2 (listener group-cancel uses
stopSending(errorCode)).
Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (M2 + M3).
https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
|
||
|
|
6cc1bf6433 |
fix(quic): RFC 9001 §4.8 TLS alert mapping + RFC 9000 §22 CRYPTO_BUFFER_EXCEEDED
The remaining 🟦 cosmetic items from the 2026-05-09 audit. Pre-fix the connection's `closeErrorCode` was effectively unused — TLS handshake failures bubbled up as generic `QuicCodecException` and observers had to grep the reason string for the spec category. Now closeErrorCode carries the RFC 9000 §20.1 transport code or the RFC 9001 §4.8 TLS-alert-mapped CRYPTO_ERROR. Implementation: * `TlsAlertException(alertCode, message)` carrier raised by the TLS layer for the well-defined cases: - HelloRetryRequest received (handshake_failure = 40) - TLS 1.3 not negotiated (protocol_version = 70) - Unsupported cipher (illegal_parameter = 47) - ALPN mismatch (no_application_protocol = 120) - Cert chain validation failure (bad_certificate = 42) - CertificateVerify signature failure (decrypt_error = 51) - Server Finished MAC mismatch (decrypt_error = 51) - TLS KeyUpdate over QUIC (unexpected_message = 10) — RFC 9001 §6 mandates this specific code; fixes the misleading prior message that said "rotation not implemented" (we DO implement QUIC's own KEY_PHASE-bit rotation). The QUIC parser maps `0x100 + alertCode` per RFC 9001 §4.8 and stamps `closeErrorCode`. * `QuicTransportError` constants object covering the RFC 9000 §20.1 table (NO_ERROR through NO_VIABLE_PATH including the previously- flagged CRYPTO_BUFFER_EXCEEDED 0x0d, KEY_UPDATE_ERROR 0x0e, AEAD_LIMIT_REACHED 0x0f). * `markClosedExternally(reason, errorCode = NO_ERROR)` overload — backward compatible; lets call sites pin the spec code on `closeErrorCode` for qlog observability without changing the wire-emit semantics (still no CONNECTION_CLOSE frame, same as before — that's a separate, larger refactor). * RFC 9000 §22 CRYPTO_BUFFER_EXCEEDED enforcement — pre-insert per-level cap of 64 KiB on inbound CRYPTO data. Generous enough for an RSA-4096 cert chain with intermediates; bounds the worst-case heap a misbehaving peer can pin to 192 KiB across all 3 encryption levels. Fires before the receive buffer actually allocates. * INVALID_TOKEN — N/A for client role (only servers validate Retry tokens). KEY_UPDATE_ERROR — exposed as a constant; no current failure path maps to it (PN regression on a key-update packet is spec-allowed to handle silently per RFC 9001 §6.1, which we do). Tests: `ErrorCodeMappingTest` (6 cases) covers TlsAlertException construction + offset, alert code bounds, markClosedExternally preservation, CRYPTO_BUFFER_EXCEEDED close, sanity-check that small CRYPTO frames don't trip the cap, and §20.1 numeric values. `HelloRetryRequestTest` updated to expect TlsAlertException(40) instead of generic QuicCodecException. Plan updated to mark the §4.8 + §22 (CRYPTO_BUFFER_EXCEEDED) items resolved; KEY_UPDATE_ERROR documented as TLS-side covered + QUIC-side spec-allowed-silent; INVALID_TOKEN documented as N/A for client. https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik |
||
|
|
cdb9d87a2e |
fix(quic): RFC 9114 §6.2.1 critical-stream auto-close + WiFi-handoff stateless-reset tokens
Two related improvements for the audio-rooms moq-lite path on mobile.
** RFC 9114 §6.2.1 + RFC 9204 §4.2 critical-stream closure **
When the peer closes a critical unidirectional stream — control (0x00),
QPACK encoder (0x02), or QPACK decoder (0x03) — the spec MANDATES we
treat it as a connection error of type H3_CLOSED_CRITICAL_STREAM.
Pre-fix the demux only set `peerH3ProtocolError` flag on protocol
violations, with no autonomous close on clean FIN; the application
was expected to poll. For audio rooms running on lossy mobile paths,
relay-side control-stream drops left users staring at silent audio.
* `Http3ErrorCode` constants for the §8.1 error-code table.
* `WtPeerStreamDemux.drainControlStream` calls `connection.close(...)`
on either clean FIN (H3_CLOSED_CRITICAL_STREAM) or QuicCodecException
raised by the frame reader (specific code via
[http3ErrorCodeForMessage] map: H3_MISSING_SETTINGS,
H3_FRAME_UNEXPECTED, H3_SETTINGS_ERROR, H3_ID_ERROR, etc.).
* New `drainCriticalStream` helper fires the same close on QPACK
encoder / decoder FIN (RFC 9204 §4.2).
* Closure intent latched on `criticalStreamClosureCode` /
`criticalStreamClosureReason` so tests can verify the path runs
without wiring a real driver.
** RFC 9000 §10.3 stateless-reset tokens for migrated CIDs **
The 2026-05-09 stateless-reset detection covered tokens in the
unused-CID pool but lost them the moment a CID was rotated to active
via `tryStartValidation` (WiFi handoff) or `forceRotateToHigherSequence`
(peer-forced retire). On the new path the migrated token wouldn't
match — relay crash mid-handoff would silently hang until idle timeout.
* `PathValidator.knownStatelessResetTokens` — append-only lifetime
store, populated in `recordPeerNewConnectionId`. Survives rotation.
* `QuicConnection.isStatelessReset` walks the lifetime store instead
of the unused pool.
* §10.3.1 explicitly permits keeping tokens after retirement; cost
is ~16 bytes per peer-issued CID.
Tests:
* `CriticalStreamClosureTest` (5 cases) — control FIN, QPACK FIN,
MISSING_SETTINGS, FRAME_UNEXPECTED, idempotency.
* `StatelessResetDetectionTest` extended with 2 cases —
`token_persists_after_path_migration_for_wifi_handoff` and
`token_persists_through_force_rotation_for_acid_reissue`.
Plan updated: 🟦 H3_CLOSED_CRITICAL_STREAM resolved; the
"limitation: tokens for actively-used DCIDs we migrated to" caveat is
removed from the §10.3 entry and from known-limitations item #5.
https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
|
||
|
|
d4ffe0474f |
fix(quic): RFC 9000 §10.2.2 DRAINING state for peer CONNECTION_CLOSE
Peer's CONNECTION_CLOSE now transitions the connection through DRAINING
for 3 * PTO before flipping to CLOSED, instead of going straight to
CLOSED. While DRAINING:
- the writer emits no packets (drainOutbound returns null);
- the parser drops late inbound silently at the top of feedDatagramInner;
- close()-awaiters unblock immediately via closingDrainSignal so the
transition isn't gated on the 3*PTO grace.
After 3 * PTO the driver's send-loop timer fires
markClosedExternally("draining period elapsed") which transitions to
CLOSED. The §10.2.2 grace period gives the peer's last retransmits a
chance to converge before we discard state.
Implementation:
* `Status.DRAINING` enum value with kdoc tying it to §10.2.2.
* `QuicConnection.drainingDeadlineMs` + `enterDraining(reason, nowMs)`
(sets status, computes `now + max(3*pto, MIN_DRAINING_PERIOD_MS)`,
completes closingDrainSignal, fires qlog) + `isDrainingExpired`.
* Parser's CONNECTION_CLOSE handler routes through `enterDraining`
instead of `markClosedExternally`.
* Driver folds `drainingDeadlineMs` into its send-loop sleep
`withTimeoutOrNull` and transitions to CLOSED on expiry.
* Writer short-circuits drainOutbound for DRAINING (returns null —
spec MUST NOT send).
* Parser drops late inbound at the top of feedDatagramInner.
Updated `FrameRoutingTest.connection_close_from_peer_short_circuits_remaining_frames`
to expect DRAINING (was CLOSED). Other status=CLOSED assertions in the
test suite cover OUR-side closes (markClosedExternally on protocol
violations / TRANSPORT_PARAMETER_ERROR / FLOW_CONTROL_ERROR) which
still go directly to CLOSED.
Test: `DrainingStateTest` (7 cases) covers the peer-close → DRAINING
transition, late-inbound drop, deadline math, the
MIN_DRAINING_PERIOD_MS floor, retransmit no-op semantics, and the
fresh-connection invariant.
Plan updated to mark all 🟡 Medium items resolved. The 2026-05-09
audit's complete spec-compliance close-out: 2 of 2 High + 6 of 6
Medium fixed in seven commits.
https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
|
||
|
|
50ea477426 |
fix(quic): RFC 9000 §10.3 stateless-reset detection
A peer that has lost connection state (crash, restart, route change)
signals so by sending a "Stateless Reset" datagram — bytes shaped like
a short-header packet whose trailing 16 bytes equal a
`stateless_reset_token` previously communicated by the peer.
Pre-fix the tokens were stored (via NEW_CONNECTION_ID into the
[PathValidator] pool, and via the peer's `statelessResetToken`
transport parameter) but never matched against arriving datagrams —
a peer's stateless reset looked indistinguishable from noise and the
connection lingered until idle timeout, or worse, an attacker could
spam look-like-noise packets toward our integrity counter.
Implementation:
* `QuicConnection.isStatelessReset(datagram)` — short-header-form
+ size + trailing-16-bytes comparison. Matches against the peer's
advertised token AND every unused entry in `pathValidator`'s
pool. Constant-time per-token compare per §10.3.1 to avoid
leaking token bits via timing.
* `PathValidator.unusedTokenForSequence(seq)` — accessor for the
above to walk the pool.
* Parser pre-empts AEAD/HP parsing: `feedDatagramInner` checks every
short-header-form datagram before the loop. False-positive rate
of a real short-header packet (whose trailing 16 bytes are the
AEAD tag) matching a known token is 2^-128, negligible in
practice. Defense-in-depth: the AEAD-failure branch in
`feedShortHeaderPacket` retains a redundant check for
second-packet-of-coalesced edge cases.
* On match, `markClosedExternally("stateless reset received from
peer")` transitions silently to CLOSED — no CONNECTION_CLOSE
emission per §10.3.1.
Limitation: tokens for an actively-used DCID we migrated to via
`PathValidator.tryStartValidation` are lost when the entry leaves
the unused pool. Acceptable for the audio-rooms scope (no migration);
documented in the kdoc.
Test: `StatelessResetDetectionTest` (5 cases) covers token-match
silent-close, unknown-trailer no-close, pool-stored token match,
long-header form rejected, too-short datagram rejected.
https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
|
||
|
|
4ec192347e |
fix(quic): RFC 9001 §6.6 AEAD invocation limit tracking
Per-key AEAD usage limits per RFC 9001 §6.6 / §B.1:
* Confidentiality limit (encrypt count per send key):
AES-128-GCM = 2^23, ChaCha20-Poly1305 = 2^62. Reaching this means
AEAD security is no longer assured; the endpoint MUST initiate a
key update or close.
* Integrity limit (forged-packet count per receive key):
AES-128-GCM = 2^52, ChaCha20-Poly1305 = 2^36. Reaching this means
an attacker has been grinding for AEAD key recovery; MUST close
with AEAD_LIMIT_REACHED.
Pre-fix neither limit was tracked. Long-running AES-128-GCM sessions
(~2hrs at 1000pkt/s) could roll past the confidentiality limit; an
attacker spamming forged packets had no failure ceiling.
Implementation:
* `Aead.confidentialityLimit` / `Aead.integrityLimit` properties
surface the spec values per cipher; AES-128-GCM and
ChaCha20-Poly1305 (commonMain singletons + jvmAndroid JCA classes)
return the §B.1 numbers.
* `QuicConnection.aeadEncryptCount` / `aeadDecryptFailureCount` —
per-key counters, reset on every 1-RTT key rotation
(`commitKeyUpdate` and `initiateKeyUpdate`).
* Writer increments encrypt count after each application-level
build. At half the limit it soft-triggers `initiateKeyUpdate`
(latched via `aeadKeyUpdateRequested` so the in-flight rotation
isn't re-issued); at the limit it closes with AEAD_LIMIT_REACHED
if rotation hasn't completed.
* Parser increments decrypt-failure count on every 1-RTT AEAD
auth-tag failure; closes when the count hits the integrity limit.
Initial / Handshake levels are out of scope — their keys' lifetime is
too short to approach the limit.
Test: `AeadInvocationLimitTest` (6 cases) covers spec-value
verification, counter increment on application send, counter reset on
rotation, confidentiality-limit close, integrity-limit close (via a
real ciphertext-tampered datagram from the in-process pipe).
https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
|
||
|
|
be96d4b28f |
fix(quic): RFC 9000 §3.2 stream state — STREAM after RESET_STREAM rejected
Once the peer has sent RESET_STREAM the receive side enters the "Reset Recvd" terminal state per §3.2; any subsequent STREAM frame on that id is a peer protocol violation and MUST close the connection with STREAM_STATE_ERROR. Pre-fix the parser silently absorbed the bytes — a peer that violated the spec would leave us with a phantom mid-reset stream the peer believed was already dead, with reset/byte bookkeeping diverging. Implementation: a per-stream `peerResetReceived: Boolean` flag set in the RESET_STREAM handler and checked at the top of the STREAM handler. The flag is independent of our local-side `resetState` (which tracks OUR RESET emission). Test: `StreamAfterResetTest` (4 cases) covers STREAM-after-reset closure, FIN-bearing STREAM-after-reset, the legal FIN-then-RESET shape (no false-positive), and per-stream isolation (reset on stream 3 doesn't poison stream 7). https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik |
||
|
|
6e054583a9 |
fix(quic): RFC 9000 §4.1 connection-level inbound flow-control enforcement
Pre-fix the receiver enforced ONLY per-stream `receiveLimit`. The
aggregate cap (`initial_max_data` / latest MAX_DATA) was advertised and
respected on the SEND side but never checked on the RECEIVE side — a
peer that opened many streams and pushed each up to its per-stream cap
could collectively exceed `initial_max_data` without us closing.
Implementation:
* `QuicStream.receiveHighestOffset`: per-stream high-water mark of
"largest stream offset received" — the spec quantity that gets
summed across streams for the connection-level check.
* `QuicConnection.connectionInboundOffsetSum`: running sum across
all streams of `receiveHighestOffset`. Updated in the parser at
STREAM and RESET_STREAM frame ingest time.
* Parser closes with FLOW_CONTROL_ERROR whenever the running sum
exceeds `advertisedMaxData`.
* RESET_STREAM final-size accounting: per §4.5 the finalSize counts
toward connection-level flow control even though no STREAM frame
delivered those bytes — a peer that resets a stream at a finalSize
larger than it had previously sent gets the extra bytes added to
the running sum at RESET arrival.
Different from the writer's MAX_DATA threshold logic (which uses the
cheaper contiguous-end approximation): the receiver-side enforcement
needs the strict spec quantity to close before bookkeeping diverges.
Test: `ConnectionLevelFlowControlTest` (5 cases) covers below-cap pass,
over-cap close, retransmit-doesn't-double-count, RESET_STREAM
finalSize counts toward limit, fresh-handshake invariants.
https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
|
||
|
|
657306392d |
fix(quic): RFC 9000 §18.2 transport-param bounds + §13.2.5 ack-delay-exponent + §7.2.4.1 reserved SETTINGS + RFC 9221 §3 outbound DATAGRAM size
Five spec-compliance fixes from the 2026-05-09 audit, all in the same
"hostile-peer hardening" tier.
* RFC 9000 §18.2 — `applyPeerTransportParameters` now closes the
connection with TRANSPORT_PARAMETER_ERROR when the peer advertises
`max_udp_payload_size < 1200`, `ack_delay_exponent > 20`, or
`active_connection_id_limit < 2`. Pre-fix the values were decoded
but never bounds-checked.
* RFC 9000 §13.2.5 — the parser's ACK-delay decoding now uses the
PEER's advertised `ack_delay_exponent` (with the §18.2 default of 3
as the pre-handshake fallback) instead of our own config value.
Defensive coercion to 0..20 is preserved so a bypass of the bounds
check above can't desync the RTT estimator.
* RFC 9114 §7.2.4.1 — `Http3Settings.decodeBody` now rejects the
HTTP/2-reserved SETTINGS ids 0x02, 0x03, 0x04, 0x05 with
H3_SETTINGS_ERROR. Pre-fix they fell through to the generic
1<<32 cap and were accepted.
* RFC 9221 §3 — the writer now drops outbound DATAGRAM frames when
the peer didn't advertise `max_datagram_frame_size` (or advertised
0), or when the encoded frame (type byte + length varint + payload)
would exceed the peer's advertised value. Pre-fix the writer
emitted DATAGRAM regardless and let spec-conformant peers close us
with PROTOCOL_VIOLATION.
* Added `TransportParameterDefaults` for the §18.2 defaults
(ack_delay_exponent=3, max_ack_delay=25ms, active_connection_id_limit=2)
so callers don't hard-code the magic numbers.
Tests: `TransportParameterBoundsTest` (8 cases) drives a real handshake
through the in-process pipe and asserts CLOSED on each violation +
CONNECTED at the boundary; `Http3ReservedSettingsTest` (5 cases) covers
the four reserved ids and the adjacent legal ids. Plan tier dropped
from 🟡 Medium to resolved for all five items.
https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
|
||
|
|
a80f4ea19d |
fix(quic): RFC 9000 §17.2/§17.3 fixed-bit + §10.1 idle-timeout enforcement
The two High-severity gaps from the 2026-05-09 four-RFC compliance audit.
Fixed-bit (RFC 9000 §17.2 long-header, §17.3 short-header): the spec
says fixed-bit (0x40) MUST be 1 in every v1 packet; receivers MUST
discard packets where it's 0. Pre-fix the parsers checked only the
form-bit (0x80) and accepted any fixed-bit value — a peer or off-path
attacker could route packets through AEAD that aren't valid v1 packets.
Both `parseAndDecrypt` paths and the `peekKeyPhase` shortcut now
silently drop fixed-bit=0. The bit isn't header-protected (HP only
XORs the low 5 bits), so the check runs on the raw wire byte before
HP unmask + AEAD. Test: `FixedBitValidationTest` (3 cases).
Idle timeout (RFC 9000 §10.1): `max_idle_timeout` was decoded into
config and advertised via the ClientHello transport-params extension,
but never enforced — a black-holed connection lived indefinitely.
This commit:
* adds `QuicConnection.lastActivityMs`, bumped on (a) successful
inbound packet decrypt and (b) outbound ack-eliciting send per
§10.1.1
* adds `effectiveIdleTimeoutMs()` returning the min of local and
peer advertisements (skipping any side that advertised 0), with
the §10.1 `3 * PTO` floor applied
* folds the idle deadline into the driver's send-loop
`withTimeoutOrNull` so an idle connection wakes exactly at expiry
* silently transitions to CLOSED via `markClosedExternally` per
§10.2.1 ("the connection enters the closing state silently —
discarding the connection state without sending a CONNECTION_CLOSE")
Test: `IdleTimeoutTest` (8 cases) covers the min-of-two computation,
0-means-disabled handling, the 3*PTO floor, deadline math, and
postpone-on-activity. Plan updated to mark both items resolved.
https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
|
||
|
|
9f7f6a9ef9 |
docs(quic): refresh stack-status plan to match shipped 2026-05-09 reality
Plan had drifted ~2 weeks behind shipping work. Rewrote to reflect the features that landed since 2026-04-26 (path validation, 0-RTT, key update, ECN, session resumption, lock-split refactor, DoS hardening) and added a fresh RFC compliance gaps section catalogued from a four-agent audit (RFC 9000, 9001, 9002, 9221+9114+9204). https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik |
||
|
|
2bb55ff9ec |
perf(quic): drop synchronized from QuicStream + replace close() polling with CompletableDeferred
Round 2 of the blocking-code audit follow-ups. Both changes remove
commonMain synchronized blocks and replace polling with event-driven
suspension; :quic:jvmTest stays green on JVM and :quic:compileAndroidMain
passes for Android.
QuicStream.resetStream / stopSending
- Replace synchronized(this) double-checked init with
AtomicReference<ResetState?>.compareAndSet(null, …) (and same for
stopSendingState). The pattern is "first call wins"; CAS expresses
that directly without a lock.
- Backing fields converted from `internal var … = null` to
`internal val … = AtomicReference(null)`. The two readers in
QuicConnectionWriter switch to .load(); QuicConnectionWriter gains a
file-level @OptIn(ExperimentalAtomicApi::class).
- The @Volatile resetEmitPending / stopSendingEmitPending writes happen
only on the winning CAS path, so the writer reading the flag still
sees the populated state via volatile happens-before.
QuicConnectionDriver.close polling loop
- Replace `while (status == CLOSING) delay(1)` polling with a
CompletableDeferred<Unit> on QuicConnection (closingDrainSignal).
The deferred is completed at both transitions to CLOSED:
(1) drainOutbound after building the CONNECTION_CLOSE datagram, and
(2) markClosedExternally on its synchronized first-call-wins path.
- close() now awaits the deferred under the existing
CLOSE_FLUSH_TIMEOUT_MILLIS bound — same upper-bound semantics, no
1 ms wakeups during teardown. complete(Unit) is idempotent, so the
two transition sites racing each other is safe.
https://claude.ai/code/session_01CXTjnuHKCNXDmpfyKxgG3V
|
||
|
|
0e4b12654d |
perf(quic): lock-free hot paths — ThreadLocal Cipher, AtomicReference close, @Volatile getters
Audit of blocking and synchronized code in the QUIC module surfaced four
hot-path wins, all verified against :quic:jvmTest.
- PlatformCrypto: header-protection AES-ECB now uses a per-thread cached
Cipher. Previously every inbound and outbound packet paid for
Cipher.getInstance("AES/ECB/NoPadding") provider lookup. ThreadLocal is
safe because the call is stateless — every invocation re-init's with the
caller-supplied key.
- JdkCertificateValidator: gate the SAN-side InetAddress.getByName behind
looksLikeIpLiteral so a malformed cert with a hostname in a type 7 SAN
cannot trigger DNS resolution on the TLS validation path.
- QuicConnectionDriver.close: replace synchronized(this) double-checked
init with AtomicReference<Job?> + compareAndSet on a CoroutineStart.LAZY
job. Lock-free, removes a synchronized from commonMain, preserves the
original "first caller wins, second awaits same Job" contract.
- SendBuffer: mark _nextOffset, nextSendOffset, _finPending, _finSent,
_finAcked @Volatile and drop synchronized from their single-field
getters (nextOffset, sentOffset, finPending, finSent, finAcked). The
compound-formula readableBytes getter still synchronizes.
https://claude.ai/code/session_01CXTjnuHKCNXDmpfyKxgG3V
|
||
|
|
2be76c898c |
fix(quic): resolve dangling KDoc lint errors
Move the feedDatagram KDoc below the MAX_QUIC_OFFSET const so it attaches to the function declaration, and merge the duplicate KDoc blocks above TlsResumptionState into a single block. |
||
|
|
df6103ffdd |
fix(quic): PSL subset, JCA ChaCha20-Poly1305, truthful ECN reporting
Round 13 — three architectural follow-ups + a closeout note.
* JdkCertificateValidator: ship a hand-picked Public Suffix List
subset covering the high-volume multi-label effective-TLDs
(multi-tenant ccTLDs and major hosting platforms). Pre-fix the
dot-count heuristic accepted `*.co.uk`, `*.s3.amazonaws.com`,
`*.github.io`, etc. — wildcards spanning these would impersonate
every co-tenant. The new MULTI_LABEL_PUBLIC_SUFFIXES set adds a
layer above the dot-count check; combined with the WebPKI / CT
ecosystem already requiring CAs to consult the full PSL when
issuing, this closes the practical attack surfaces. Full
~9000-entry PSL data shipping is still deferred (data-shipping
ask, doc'd); a domain not in the subset that's also a multi-label
ETLD remains a gap.
* JcaChaCha20Poly1305Aead: new JCA-backed implementation mirroring
JcaAesGcmAead's shape (cached Cipher + SecretKeySpec, range
overloads via Cipher.doFinal(input, off, len, output, outOff),
recent-nonce history for legitimate IV reuse on the
Initial-padding rebuild path). bestChaCha20Poly1305Aead(key)
expect/actual factory tries the JCA path first (Java 11+ /
Android API 28+) and falls back to the pure-Kotlin
ChaCha20Poly1305Aead singleton if the algorithm isn't available
(older Android, headless GraalVM native-image without the
standard providers). PacketProtectionBuilder routes the
ChaCha20-Poly1305 cipher suite through the factory instead of
the singleton. On supporting platforms this gives the same
outbound-allocation savings as round 8's AES-GCM range overload.
* QuicConnectionWriter: stop emitting fake ECN counts on ACK
frames. Pre-fix every 1-RTT ACK carried `AckEcnCounts(0, 0, 0)`
— claiming to track ECN while actually never reading inbound TOS
bits. RFC 9000 §13.4.2: "An endpoint that uses ECN MUST report
accurate ECN counts." Hardcoded zeros could be flagged as a
PROTOCOL_VIOLATION by strict peers cross-validating against
outbound packet counts; aioquic / picoquic / quic-go tolerate
it but other stacks may not. With ecnCounts = null we honestly
advertise "this endpoint isn't reporting ECN", peer skips its
own ECN-driven congestion logic for our direction. We still
mark outbound ECT(0) (other peers' tracking benefits from the
path-quality signal); RFC 9000 §13.4 allows the asymmetry.
* MutableSharedFlow migration for QuicStream.incoming declined as
obviated. The audit's suggestion was a workaround for the
cancel-coupling specifically (collector cancel → channel cancel
→ INTERNAL_ERROR) — round 11's `flow { for (c in
incomingChannel) emit(c) }` wrapper solved that. Switching to
MutableSharedFlow would change the semantics from "each byte to
exactly one consumer" (correct for stream bytes) to fan-out
(every emission to all collectors), which is wrong for QUIC
stream data.
All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 40s.
https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
|
||
|
|
953869714b |
fix(quic): visibility, scratch caching, retransmit coalescing, secret hygiene
Round 12 — six smaller follow-ups across visibility, perf, and secret-handling. * QuicStream.receiveDirtyForFlowControl gains @Volatile. The parser's read-loop writes the flag and the writer's send-loop reads it WITHOUT holding the same lock; without volatile the writer could miss the parser's update for an unbounded time on JVM (the field is hot in a drain loop where the JIT might cache it), suppressing MAX_STREAM_DATA emissions until something else triggered a fresh load. * SendBuffer.readableBytes runs in O(1) instead of O(R) by maintaining a cached `retransmitTotalBytes` counter. Updated in lockstep with every retransmit deque mutation: addLast in [requeueAllInflight] + the two paths in [removeOverlap] (RETRANSMIT zero-length + main range), and add/removeFirst in [takeChunk]. Pre-flight "anything to send?" check on the writer's hot path was previously walking the deque per-stream per-drain. * SendBuffer.requeueAllInflight coalesces adjacent ranges on insert. Pre-fix the PTO probe path appended each in-flight range as a separate retransmit entry, so on the next drain takeChunk emitted one tiny STREAM frame per original-packet boundary. With coalescing, contiguous bytes get replayed as one chunk + one AEAD seal. FIN-bearing ranges stay separate (merging across a FIN changes the implicit final-size invariant). * TlsResumptionState dropped `data class`. The auto-generated equals/hashCode used reference equality on its ByteArray fields (PSK / ticket / peerTransportParameters), so two byte-identical states compared unequal — almost never useful and a footgun for caller-side caches. The auto-toString would dump PSK contents into any log. Replaced with hand-written equals/hashCode using contentEquals on the byte fields and a redacted toString that reveals only sizes. * QuicConnectionParser RESET_STREAM handler bounds finalSize at [0, 2^62-1] per RFC 9000 §16 (the QUIC offset ceiling). Pre-fix we accepted any varint, including values that could overflow downstream Long math. * PathChallenge/PathResponse IAE leak: re-traced and verified non-issue. The decoder calls `r.readBytes(8)`, which either throws QuicCodecException (short read) or returns exactly 8 bytes — the constructor's `require(data.size == 8)` is unreachable from the decode path. The audit was over-cautious; no code change. All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 39s. https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM |
||
|
|
e7b7d99582 |
perf(quic): outbound AEAD allocation, scratch reuse, sort skip + key-update + flow
Round 11 — six follow-ups across perf, correctness, and API hygiene.
* QuicStream.incoming: replace `consumeAsFlow()` with `flow { for (c in
incomingChannel) emit(c) }`. Pre-fix the consume-style flow
cancelled the underlying Channel when the collector terminated,
which coupled "application stopped reading" with "parser
INTERNAL_ERRORs the connection on next delivery". The new emit-only
flow leaves the channel open across collector cancellation, so
applications can stop reading temporarily and resume later
(sequential collects, not concurrent — that's still a race on the
channel iterator).
* RFC 9001 §6.1 client-initiated key update: the existing
[QuicConnection.initiateKeyUpdate] no longer requires the caller
to enforce spec invariants. Returns false if the handshake isn't
yet complete (§6.5: MUST NOT initiate before HANDSHAKE_DONE) or if
a previous rotation is still in flight (§6.4: MUST NOT initiate a
subsequent update until the previous one is confirmed). The parser
clears the [keyUpdateInProgress] flag on the first inbound packet
that AEAD-decrypts under the post-rotation live keys — the
confirmation signal that the peer has rolled forward.
* Aead.sealInto: new range + in-place seal that writes ciphertext+tag
DIRECTLY into a caller-supplied output buffer at a given offset.
Default impl falls back to sealRange + copy; JcaAesGcmAead overrides
to use Cipher.doFinal(input, inOff, inLen, output, outOff).
LongHeaderPacket.build / ShortHeaderPacket.build now pre-allocate
the final packet buffer in a single shot and have the AEAD write
ciphertext+tag in-place. Pre-fix every outbound packet allocated
4 ByteArrays (headerBytes, paddedPlaintext, ciphertext, concat
buffer); now ~2 (the final packet + the AEAD provider's internal
scratch).
* QuicConnectionWriter.drainOutbound: skip the
`active.sortedByDescending { priority }` allocation when EVERY
active stream shares the same priority — not just the
default-zero case. A homogeneous priority-7 workload now keeps
insertion order at no cost.
* QuicConnection.scratchAppFrames / scratchAppTokens: per-connection
reusable lists for buildApplicationPacket. Cleared at function
entry, re-used across drains under streamsLock's single-writer
guarantee. The tokens list is `.toList()`-snapshotted into the
SentPacket record before reuse, so retransmit dispatch is
unaffected. NOT applied to collectHandshakeLevelFrames because
its returned [HandshakeLevelContents] is held across two
buildLongHeaderFromFrames calls (natural-size + padded rebuild)
in drainOutbound.
* Removed dead `parts = mutableListOf<ByteArray>()` declaration at
the top of drainOutbound — never referenced; the actual datagram
assembly uses inline `listOfNotNull(...)` instead.
All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 43s.
https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
|
||
|
|
b097580fdd |
fix(quic): TLS PSK rejection — recover in-place instead of failing
Round 10 — supersedes the typed-exception approach from round 9. Round 9 introduced [PskRejectedException] as a "punt to the application layer" hack, with a comment claiming the in-place fallback was too subtle to land safely. After tracing the actual derivation path the fix turns out to be one line. The key observation: the on-wire ClientHello (with `pre_shared_key` extension and binder bytes) goes into BOTH client and server transcript hashes regardless of accept / reject (RFC 8446 §4.2.11). The transcript hash itself doesn't need any rebuild. The ONLY thing that differs between accept and reject is how [earlySecret] is derived: * accepted: HKDF-Extract(0, PSK) * rejected: HKDF-Extract(0, 0) ← same as no-resumption path So when the server returns ServerHello without `pre_shared_key`, we simply call `keySchedule.deriveEarly()` to overwrite the PSK-derived [earlySecret] with the zero-keyed value. [deriveHandshake] runs immediately after with the new earlySecret + ECDHE shared secret, and the handshake proceeds along the regular non-resumption path (which is well-tested by every non-resumption test in the suite). * `pskAccepted = false` so the WAITING_CERTIFICATE_OR_FINISHED state correctly demands Certificate + CertificateVerify (a Finished without those would still be rejected as unauthenticated). * Any 0-RTT packets the application emitted under the PSK-derived [clientEarlyTrafficSecret] are lost — server can't decrypt them and EncryptedExtensions arrives without the early_data extension, so [earlyDataAccepted] = false. The application layer is responsible for replaying any 0-RTT-bound payload over 1-RTT. The handshake itself proceeds cleanly. * [PskRejectedException] (added in round 9) deleted as dead code. [QuicCodecException] reverts to a `final` class. All 269 :quic:jvmTest tests pass. The fallback re-uses the deriveEarly() codepath that every non-resumption test exercises, so test coverage is implicit in the existing suite. https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM |
||
|
|
28c1a355da |
fix(quic): RFC 9002 §6.1.2 loss-detection timer + PSK rejection signal
Round 9 — the two largest items remaining from the audit.
* RFC 9002 §6.1.2 timer-driven loss detection. [detectAndRemoveLost]
now returns a [Result] data class carrying both the list of lost
packets and the absolute monotonic deadline at which the next
earliest sub-largest in-flight packet will cross the time threshold.
The parser stores that deadline on each [LevelState.nextLossTimeMs]
per encryption level, and the driver's send loop now sleeps for
`min(ptoDeadline, minNextLossTimeAcrossLevels) - now`. On expiry,
the driver distinguishes:
* Loss-timer wake → run [detectAndRemoveLost] across all levels;
declare time-threshold-lost packets and dispatch their tokens.
No probe budget, no exponential backoff.
* PTO wake → existing [handlePtoFired] path (probe + backoff).
Pre-fix tail loss waited for the full PTO (often 5x the time
threshold) before retransmitting because we had no event between
ACK arrivals to fire loss detection. Now `9/8 * max_rtt` is the
ceiling.
* TLS PSK rejection: instead of the prior generic [QuicCodecException]
("server rejected PSK; full-handshake fallback not implemented"),
raise a typed [PskRejectedException] subclass. Application reconnect
logic can selectively catch this and retry the handshake without
cached resumption state — a path that's correct by construction
(fresh ClientHello, no PSK history, no transcript-rebuild
complexity). [QuicCodecException] is now `open` so the subclass
can extend it.
In-place fallback (clear early secret, rebuild transcript without
PSK extension, replay derivation) remains deferred — the subtle
transcript-hash discrepancies it could introduce would be much
harder to debug than a hard failure that the application
intentionally turns into a retry.
All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 47s.
https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
|
||
|
|
90f59a3351 |
perf(quic): AEAD range overload + doc notes for known fragile couplings
Round 8 — AEAD allocation reduction on the receive hot path, plus the documentation of two known-fragile-but-not-broken couplings. * Aead gains [openRange] / [sealRange] taking (array, offset, length) triples for AAD and plaintext/ciphertext. Default impls slice and delegate to the existing whole-array methods so non-JCA AEADs (Aes128Gcm singleton, ChaCha20Poly1305Aead) still work. The JCA-backed [JcaAesGcmAead] overrides both, passing offsets straight to `Cipher.updateAAD(byte[], offset, len)` and `Cipher.doFinal(byte[], inputOffset, inputLen)` — JCA accepts ranges natively and does no internal copies. Saves ~2 KB ByteArray allocations per inbound packet (one for the header `aad`, one for the `ciphertext`) — at audio-room receive rates (~100 packets/sec) that's ~12 MB/min of GC churn eliminated. Same overload on the outbound path is wired but currently exercised less because the build path constructs `headerBytes` and `paddedPlaintext` as separate fresh allocations. parseAndDecrypt in both ShortHeaderPacket and LongHeaderPacket now call openRange instead of slicing into intermediates. * JdkCertificateValidator.dnsMatches: explicit doc note on the public-suffix-list gap. The dot-count heuristic accepts wildcards like `*.co.uk` / `*.github.io` / `*.s3.amazonaws.com` whose effective TLD spans multiple labels. WebPKI / CT logging mitigates this in practice (CAs validate against the actual PSL), but our local validation alone wouldn't catch a rogue cert. Production callers for sensitive endpoints should rely on OS / NetworkSecurityConfig pinning rather than QUIC's hostname check alone. Full PSL data shipping deferred until justified. * QuicStream.incoming: explicit doc note on the single-collector contract. [consumeAsFlow] cancels the underlying [Channel] when its collector terminates, and once cancelled the parser's next trySend fails, sets [overflowed] = true, and the parser tears down the connection with INTERNAL_ERROR. Production callers already follow single-collector + collect-until-FIN, but the doc lays out the rule so a future refactor doesn't loosen it accidentally. Long-form discussion of the `MutableSharedFlow` alternative and why we declined it. All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 40s. https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM |
||
|
|
f987b3dfe2 |
fix(quic): SETTINGS validation, sensitive headers, peer-stream count, drain alloc
Round 7 — closing out the audit's spec/correctness/perf quick wins.
* WtPeerStreamDemux: validate peer SETTINGS includes ENABLE_WEBTRANSPORT=1
AND ENABLE_CONNECT_PROTOCOL=1 (draft-ietf-webtrans-http3 §3 +
RFC 8441). Pre-fix we accepted any SETTINGS and proceeded to
Extended CONNECT, which the server then rejects with no
diagnostic for the application. Now surfaces as a typed protocol
error on peerH3ProtocolError so the QUIC layer closes deliberately.
* QpackEncoder: set N=1 (never-indexed) on literal field lines for
authorization / cookie / set-cookie / proxy-authorization per
RFC 9204 §4.5.4. Pre-fix sensitive credentials could be cached
by intermediate QPACK encoder caches.
* QuicConnection.getOrCreatePeerStreamLocked: track peerInitiated*Count
via max(current, peerIndex+1) instead of += 1. The counter now
derives from the stream id's index field (RFC 9000 §2.1) and is
idempotent against retransmits-after-eviction. Pre-fix a peer
retransmit on a stream id that aged out of the retired-IDs FIFO
bumped the counter again, eventually triggering spurious
MAX_STREAMS_* emissions.
* applyPeerRetireConnectionIdLocked: reclassify the close-on-seq=0
case as PROTOCOL_VIOLATION (peer fault) instead of INTERNAL_ERROR
(our fault). The diagnostic was misleading — the peer IS
misbehaving (asking us to retire our only SCID with no
replacement available), not us.
* QuicConnectionWriter.drainOutbound: skip the
`streamsView.filter { !it.isClosed }` allocation when no streams
are closed. Quick `any` scan first; only allocate the filtered
list when at least one stream is actually closed. Saves an
N-sized ArrayList per drain in the common case (~50 drains/sec
× N up to ~2000 streams under multiplex load).
All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 40s.
https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
|
||
|
|
5421b81569 |
perf(quic): QPACK Huffman decode — no boxing, IntArray + binary search
Pre-fix QpackHuffman.decode allocated two boxed Integers per output character: one for the `candidate` Int passed into `HashMap<Int, Int>.get` and one for the wrapper-Integer return value. Output also went through `ArrayList<Byte>`, boxing every emitted byte as a `java.lang.Byte` (~16 bytes per output byte on a 64-bit JVM). On a typical HTTP/3 response with ~30 header values, that's hundreds of throwaway wrapper objects per request — pure GC churn on the hot path. The new layout keeps two parallel `IntArray`s per code-length: codes[len] (sorted ascending) and syms[len] (the matching symbol indices). Lookup is a primitive `IntArray.binarySearch(candidate)` — no boxing, the array stays in JIT-friendly contiguous memory, and the per-length arrays are tiny (a few entries each, since the Huffman table is sparse at any given length). Output uses a growable `ByteArray` with a manual position index rather than `ArrayList<Byte>`. Pre-grow to 2× input size as a rough upper bound — ASCII headers compress to ~62% with HPACK Huffman, so we rarely need to grow. Also fixes a latent bug: the new init loop covers lengths 5..30 (previously 5..29), restoring decoding for symbols 10 (LF), 13 (CR), 22 (DC2) which all use 30-bit codes per RFC 7541 Appendix B. Pre-rewrite the HashMap path included these via `for (sym in 0..255)` walking the full symbol table; the IntArray rewrite needed an explicit length range and accidentally cut at 29. Added a unit test exercising hand-encoded length-30 inputs to lock the fix in. Behavior verified against RFC 7541 Appendix C test vectors and the new length-30 round-trip. All 269 :quic:jvmTest tests pass. https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM |
||
|
|
ce8d852518 |
fix(quic): final stabilization sweep — CID picking, atomic gates, defensive checks
Round 5 of the audit follow-ups. The remaining low-leverage items from the original audit all addressed in one pass. * PathValidator: pick the smallest spare CID sequence number rather than LinkedHashMap insertion order. RFC 9000 §19.15 lets the peer issue NEW_CONNECTION_ID out of sequence (e.g. retransmits arriving after newer offers); insertion-order picking would land on whichever offer arrived first instead of the lowest seq, drifting away from the RFC-expected ordering. forceRotateToHigherSequence also now filters >= retirePriorToWatermark explicitly so we never pick a sequence below the watermark even if the pool somehow holds one. * QuicWebTransportSessionState.close: driver.wakeup() AFTER enqueuing the WT_CLOSE_SESSION capsule + FIN but BEFORE driver.close, so the capsule actually reaches the wire instead of being short-circuited by the driver shutdown. Pre-fix the peer saw an abrupt UDP-level tear-down with no application-error-code surfaced. * QuicStream.resetStream / stopSending: synchronized(this) atomic CAS for the "first call wins" gate. Pre-fix two concurrent callers could both observe `resetState == null` and both write — the second caller's errorCode would clobber the first while resetEmitPending was already set, so the writer emitted the RESET_STREAM with whichever value landed last. * Http3Settings.decodeBody: per-id value range checks. A peer that advertises e.g. MAX_FIELD_SECTION_SIZE = 2^60 could otherwise drive our encoder into unbounded heap. Bounds chosen above any legitimate value (1 GiB for table-capacity / field-section caps, 1 for boolean flags) and below 2^32 for unknown ids. * Privatize crypto-relevant static byte arrays: InitialSecrets.V1_INITIAL_SALT, RetryPacket.V1_RETRY_KEY, RetryPacket.V1_RETRY_NONCE. Pre-fix these were public mutable ByteArrays — any caller could stomp on them, and any toString / reflection would leak the bytes. Crypto material doesn't need to be reachable outside the deriving / sealing path. * peekHeader length cast: re-verified as already safe (lengthRaw is bounded by r.remaining before .toInt() — Int.MAX_VALUE ceiling enforced). All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 2m 17s. https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM |
||
|
|
e4d96421c2 |
fix(quic): reserved-bit checks, TLS bounds, SendBuffer shrink
Round 4 of the audit follow-ups.
* Reserved-bit enforcement on unmasked QUIC headers (RFC 9000 §17.2 /
§17.3.1). Pre-fix the parser silently accepted long-header packets
with bits 0x0C set or short-header packets with bits 0x18 set after
HP unmasking — the spec mandates PROTOCOL_VIOLATION close. Added
[QuicProtocolViolationException] and a top-level catch in
feedDatagram that translates the throw into markClosedExternally.
Long-header parse also drops a now-dead `if form==1` branch on the
first-byte mask: we already early-returned in non-long paths above,
so the mask is always 0x0F.
* TLS handshake-message bounds:
- TlsCertificateChain.decodeBody: reject `listLen > r.remaining`
up front; assert `r.position == end` after the cert loop.
Without this, a malicious peer could push us into reading past
the message limit on per-cert extensions.
- TlsServerHello.decodeBody / TlsEncryptedExtensions.decodeBody:
reject trailing bytes after the extensions block.
- TlsEncryptedExtensions.alpn: enforce RFC 7301 §3.1 (server
returns EXACTLY one protocol_name); validate outerLen matches
remaining and reject multi-name responses.
* SendBuffer.data shrink: pre-fix the doubling-on-grow buffer never
shrank, so a stream that ever held N bytes pinned `data.size = N`
for the connection's lifetime. Long-tail memory retention on
per-stream basis. advanceFlushedFloorIfPossible now releases
capacity once live bytes occupy ≤ 1/4 of the allocation, shrinking
to max(SHRINK_FLOOR_BYTES=4096, 2*dataLen). Below the floor the
doubling cost is negligible; above it the multi-MiB transients
release back to the heap.
All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 47s.
https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
|
||
|
|
392df0384b |
fix(quic): HTTP/3 stream-context validation + ReceiveBuffer perf cliff
Round 3 of the audit follow-ups.
* Http3FrameReader gains a [StreamContext] parameter that enforces
RFC 9114 §7.2 per-stream rules:
- CONTROL: first frame MUST be SETTINGS (else H3_MISSING_SETTINGS);
duplicate SETTINGS, DATA, HEADERS, PUSH_PROMISE all forbidden.
- REQUEST: SETTINGS / GOAWAY / MAX_PUSH_ID / CANCEL_PUSH forbidden.
- PUSH: similar set including PUSH_PROMISE.
- Reserved types 0x02 / 0x06 / 0x08 / 0x09 explicitly rejected.
- WT_BIDI_DATA / WT_UNI_DATA: reader is the wrong tool, throw.
- UNCHECKED preserves prior test behaviour and is the default.
WtPeerStreamDemux's CONTROL drain now constructs the reader with
StreamContext.CONTROL, so a buggy server can no longer slip a DATA
frame into our SETTINGS expectations and silently confuse the
parser. The validation throws QuicCodecException, which the
drainControlStream catch records on a new peerH3ProtocolError
field — the QUIC layer / application reads it to close with the
proper diagnostic instead of having the route() catch swallow it.
* ReceiveBuffer no longer coalesces overlapping segments on insert.
Pre-fix every reorder fill allocated a fresh merged ByteArray of
size (hi - lo) and copyInto'd each existing segment — under a 200-
chunk reorder burst that was O(N²) bytes. The new layout keeps
segments as a sorted, non-overlapping list (binary-searched on
insert) and only allocates at readContiguous time, where it walks
consecutive segments and concats them in a single pass. Adjacent
segments are not eagerly merged — the read-side concat is bounded
by the contiguous prefix the consumer is about to drain anyway.
bufferedAhead becomes O(1) (cached counter) instead of O(N) sum.
* New tests cover the per-context rejection paths (CONTROL-stream
first-frame check, DATA-on-control, SETTINGS-on-request, all four
reserved frame types).
All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL.
https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
|
||
|
|
5c534b1774 |
fix(quic): bound peer-controlled buffers and channels — DoS hardening
Round 2 of the audit follow-ups. Each item caps a peer-controlled allocation that pre-fix could be inflated to hold gigabytes of heap or pin a CPU core indefinitely. * Http3FrameReader: cap pending unparsed buffer (1 MiB) and per-frame body length (16 MiB). A peer streaming a partial-frame prefix without ever delivering the body now raises QuicCodecException instead of growing buf indefinitely. * CapsuleReader: cap pending buffer (1 MiB) and per-capsule body (64 KiB). Symmetric encoder-side check on WT_CLOSE_SESSION reason size, matching the existing decoder cap. * WtPeerStreamDemux: replace Channel.UNLIMITED with bounded channels + suspending sends. readyStreams now caps queued peer-initiated streams at 1024; per-stream chunkChannel caps at 64 chunks. The collector's suspending send naturally back-pressures via QUIC flow control when the application is slow, rather than pinning heap. * WtDatagram.decode: validate quarter-id is in [0, (2^62-1)/4] so `r.value * 4` cannot overflow Long and wrap into a small signed value matching our session id (cross-session datagram injection). * QuicReader.readBytes / skip: translate negative-count into typed QuicCodecException instead of letting IllegalArgumentException escape from copyOfRange. * AckTracker: cap stored disjoint ranges at 64. A peer that sends alternating-bit-pattern PNs can no longer grow our ACK frame past what fits in a packet; oldest range evicts on overflow. * JcaAesGcmAead: track recent encrypt nonces (8) instead of just the most-recent, so a single intermediate seal between two rebuilds can't mask a duplicate against the second-most-recent. Drop the remembered nonce on doFinal failure so a retry takes the safe fresh-Cipher path. Add synchronized() defence-in-depth. Each cap has a generous default (above any legitimate use) but finite. Tests use no-arg construction; existing call sites unaffected. All 269 :quic:jvmTest tests pass. https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM |
||
|
|
b25644bf8b |
fix(quic): stabilization pass — concurrency, RFC compliance, DoS hardening
Verified and applied 12 focused fixes from a four-agent review of the quic module. Each fix verified against the actual code; agent findings that traced to false positives (pendingPing clear-without-emit, sentPackets on encrypt failure) are documented in the review thread but not changed. Concurrency / flakiness: - PTO consecutive count: double-increment removed; now incremented exactly once per PTO event in handlePtoFired before requeue, so the threshold check inside requeueInflightForProbe sees the post- increment value AND the between-probe re-requeue doesn't bump again. - Wallclock → monotonic: QuicConnection.nowMillis defaults to TimeSource.Monotonic anchored at construction, so NTP step / suspend-resume can't poison RTT samples. Driver uses connection.nowMillis instead of carrying its own clock. - Close-state race: atomic CAS via closeStateMonitor in close() and markClosedExternally so concurrent teardown paths can't both fire qlog "connection closed" and stomp on closeReason. - streamsList CME: converted to @Volatile var List<QuicStream> with immutable-snapshot publishing under streamsLock. closeAllSignals' iteration is now CME-free without holding the (suspending) lock. - JcaAesGcmAead: synchronized seal/open; multi-entry recent-nonce history (was single most-recent — could mask a duplicate against the second-most-recent under intermediate seals); on seal failure evict the cached nonce so a retry with the same nonce takes the safe fresh-Cipher path. Wire correctness / spec: - Connection-level send credit no longer debited on retransmits (added Chunk.isRetransmit; writer skips sendConnectionFlowConsumed += data.size when set). Pre-fix a few PTO rounds on a long stream exhausted credit and stalled the connection. - ACK-delay shl overflow: clamp ackDelayExponent to 0..20 and clamp the peer's varint to (Long.MAX_VALUE >>> exponent) before shift; clamp negative now-vs-recv-time before shift on outbound AckTracker. - Key-update commit only when new-phase packet PN exceeds largestReceived (RFC 9001 §6.1). - RESET_STREAM final-size validation: enforce equality with prior FIN size and ≥ highestObservedOffset; close FINAL_SIZE_ERROR otherwise. - STOP_SENDING handling: respond with RESET_STREAM on the local send side (was silently dropped, peer's flow-credit wasted). - ReceiveBuffer.insert: typed InsertResult; second FIN with conflicting size and offset-past-FIN data both surface FINAL_SIZE_ERROR via the parser instead of being silently dropped. - Retry SCID==DCID self-loop: reject Retry where the peer's SCID equals our original DCID (RFC 9000 §17.2.5.2). DoS hardening: - ACK PN walk: drainAckedSentPackets rewritten to scan in-flight keys against parsed ranges instead of walking every PN. A peer with firstAckRange = 2^62-1 used to pin a core forever; now bounded by the sent-packets map size. - UDP socket: channel.connect(remote) after bind so the kernel filters off-path datagrams. Stops trivial source spoofing from burning AEAD attempts. https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM |
||
|
|
0c4bf031f1 |
fix(quic): RFC 9002 §6.2.4 — emit two ack-eliciting packets per PTO probe
Single-packet probes need 6 PTO doublings (~19s) to land one datagram through the `amplificationlimit` interop scenario's 6-drop window. quic-go and msquic kill the connection at ~10s of silence regardless of our handshake-timeout budget, so we never recovered against them (diagnosed in the parent investigation; the 10s→30s timeout bump in 0a892b0d4b only fixed picoquic). RFC 9002 §6.2.4 allows up to 2 ack-eliciting packets per PTO. Adding the second probe halves recovery to ~3 PTO rounds (~5s) and lands within strict server tolerances. Wiring: - New `QuicConnection.pendingProbePackets`, set to 2 by handlePtoFired. - Extracted `requeueInflightForProbe` from handlePtoFired so the send loop can re-requeue inflight CRYPTO / STREAM bytes between probes. - Send loop decrements the budget after each probe-bearing send; if the budget is still positive, re-requeues AND re-arms `pendingPing` so the no-data fallback (post-handshake idle) still emits a second PING. Without the `pendingPing` re-arm, only the first probe fires when CRYPTO is fully ACK'd — `pendingPing` is one-shot in collectHandshakeLevelFrames. Verified end-to-end: - amplificationlimit: ✕→✓ vs quic-go (35s→7s); ✓ no-regression vs picoquic (19s→7s) and quinn (15s→7s); msquic now reports server- side UNSUPPORTED (was failing). Recovery times across the board drop ~3x because handshake-loss recovery is ~3 PTO rounds instead of ~6. - handshake / transfer / multiplexing / handshakeloss all green vs quic-go, quinn, picoquic, msquic — no regression on the core matrix. Tests: - New `ptoEmitsTwoProbePacketsPerRfc9002` in PtoCryptoRetransmitTest invokes the EXACT helpers the send loop uses (handlePtoFired then requeueInflightForProbe between drains) and asserts two distinct Initial datagrams with the same CRYPTO bytes at offset 0 on distinct PNs. Verified the test fails when budget is reverted to 1. - Existing PTO + recovery tests stay green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
7ac70498ac |
fix(quic-interop): bump HANDSHAKE_TIMEOUT_SEC 10s → 30s for amplificationlimit
The amplificationlimit testcase scenario drops client→server packets 2–7. Recovering past 6 consecutive drops via RFC 9000 PTO doublings (0.3+0.6+1.2+2.4+4.8+9.6 ≈ 19s) is more than the previous 10s budget allowed — we declared handshake_failed mid-recovery. Bumping to 30s matches the multiconnect handshake budget and gives clean PTO headroom. Fixes: amplificationlimit ✕→✓ vs picoquic. No regression vs quinn (was already passing at ~14s). Normal handshakes complete in <1s so the bump is invisible outside lossy paths. Still fails: amplificationlimit vs quic-go and msquic. Their server-side handshake-progress watchdog gives up at ~10s of silence regardless of our budget. The proper fix is RFC 9002 §6.2.4 — send 2 ack-eliciting packets per PTO probe instead of 1, halving the recovery time for consecutive drops. That's a writer-side change, deferred. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
9ad4dbc356 |
fix(quic): replace deprecated lock with split locks in QlogObserverTest
The post-handshake status check uses lifecycleLock (status is guarded by lifecycleLock per the lock-split refactor); the malformed-datagram test uses streamsLock since feedDatagram requires streamsLock. |
||
|
|
07ba23a71c |
fix(quic): second-pass audit fixes for path validation
Three correctness bugs surfaced by post-fix re-audit, plus minor
cleanups.
- Bug A (validation hang): checkPathValidationTimeoutLocked was
only called from handlePtoFired. PATH_CHALLENGE is ack-eliciting
so the peer ACKs it; that ACK resets consecutivePtoCount, which
means the PTO timer that used to host the budget check stops
firing. Validation could hang indefinitely on a peer that ACKs
but doesn't reply with PATH_RESPONSE. Fix: drive the budget
check from drainOutbound (every send-loop wake).
- Bug B (stale retire): under abrupt-migration semantics the
prior CID is abandoned the moment we rotate. Queuing the retire
only inside applyPathResponse meant two consecutive failed
validations would leave the original seq=0 unretired forever.
Fix: queue priorSeq in tryStartValidation; advance
activeCidSequence at trigger time so it tracks the on-wire DCID.
- Bug C (spec MUST violation): RFC 9000 §5.1.2 requires server-
forced retirement of the active CID when the peer's
retire_prior_to advances past it. Previously the parser silently
accepted the offer and we kept stamping a now-retired CID.
Fix: new PathValidator.forceRotateToHigherSequence; called from
applyPeerNewConnectionIdLocked after a successful Stored result.
No PATH_CHALLENGE needed (same path, just different CID).
Closes connection with CONNECTION_ID_LIMIT_ERROR if the pool
is empty when forced rotation is needed.
Concurrency:
- Add @Volatile to consecutivePtoCount. The driver kdoc claimed
it already was; it wasn't. The send-loop reads it lockless for
backoff calculation while three writers mutate it (driver PTO
fire, parser ACK reset, applyPeerPathResponseLocked reset).
Cleanup:
- Drop redundant destinationConnectionId re-stamp in
applyPeerPathResponseLocked (already rotated at challenge time).
- Fix PathMigrationResult kdoc to acknowledge that NotConnected
is produced only by the connection-level wrapper.
- Update applyPeerNewConnectionIdLocked kdoc with the §5.1.2
forced-rotation contract.
Tests:
- PathValidatorTest:
+ triggerRetiresPriorSequenceImmediately (Bug B)
+ twoConsecutiveFailedValidationsRetireAllAbandonedSequences
(Bug B regression — would have caught the original miss)
+ forceRotateRunsWhenWatermarkPassesActiveCid (Bug C)
+ forceRotateNoOpWhenWatermarkBelowActive (Bug C edge)
+ forceRotateRotatesAgainWhenNewerOfferAdvancesWatermark (Bug C cascading)
- ClientPathMigrationTest:
+ newConnectionIdWithRetirePriorToPastActiveForcesRotationOnSamePath
(Bug C wire-level)
+ Updated fullMigrationRoundTrip to assert RETIRE rides in the
same packet as PATH_CHALLENGE under abrupt-migration semantics.
+ Updated pathResponseWithMismatchingPayloadKeepsValidatingAndDcid
to reflect activeCidSequence advances at trigger time.
All :quic:jvmTest (39 tests in path-validation suite) and
:nestsClient:jvmTest pass.
https://claude.ai/code/session_01PVVhSQXvw4K4oQ46FzpgaT
|
||
|
|
9b9ede2e1e |
fix(quic): audit fixes for client path validation + DCID rotation
Addresses seven bugs surfaced by post-landing audit of the path
validation feature.
Spec fixes (RFC 9000 §9):
- Bug 1: PATH_CHALLENGE was going out on the OLD DCID because the
writer reads conn.destinationConnectionId per packet and the
rotation only happened on PATH_RESPONSE arrival. Now rotate the
DCID inside triggerPathMigrationLocked (abrupt-migration model
appropriate for the "old path looks dead" trigger condition).
Fixes the headline feature — without this the challenge cannot
actually exercise the new path.
- Bug 2: 3 * PTO timeout dropped the failed CID without queuing a
RETIRE_CONNECTION_ID. The peer kept the routing entry forever.
checkValidationTimeout now queues the failed sequence per §5.1.2.
- Bug 3: RETIRE_CONNECTION_ID for seq 0 was silently honored. We
have no replacement SCID to give the peer (we don't issue our
own NEW_CONNECTION_ID frames), so the connection is unusable.
Close with INTERNAL_ERROR instead.
- Bug 4: triggerPathMigration had no handshake-confirmed gate;
§9.1 forbids migration before handshake confirmation. Returns
new PathMigrationResult.NotConnected when status != CONNECTED.
Implementation fixes:
- Bug 5: driver was calling Clock.System.now() directly instead
of conn.nowMillis(), breaking virtual-clock tests.
- Bug 6: PTO threshold check ran BEFORE the consecutive-PTO
counter increment, so threshold=2 actually required 3 PTOs.
Increment first; threshold semantics now match the constant.
- Bug 7: applyPeerPathResponseLocked didn't reset
consecutivePtoCount on successful validation; the next sleep
inherited a stale exponential-backoff multiplier even though
the peer just proved liveness.
Code quality:
- Rename ValidationOutcome.Validated.newConnectionIdBytes →
connectionId; PathValidationState.Validating.newCidBytes →
newConnectionId. The "Bytes" suffix was redundant.
- Drop unused PathValidator(initialActiveCidSequence) parameter.
- Drop dead coerceAtLeast(2) in pool size calculation.
- Make pendingChallenges and pendingRetireSequences internal.
- Fix stale KDoc references (activatePendingValidatedCid,
forceRetireActiveIfNeeded, "retirePriorTo decreased" — none
survived the §19.15 clamp fix).
- PathValidator.RecordResult: drop RetirePriorToRegressed
enum value (clamped, never returned).
- Surface qlogObserver.onConnectionIdRetired in both the
success and timeout paths.
Tests:
- ClientPathMigrationTest: existing fullMigrationRoundTrip
test now asserts DCID rotates AT challenge time, not on
PATH_RESPONSE.
- New retireConnectionIdForSequenceZeroClosesConnection.
- New pathResponseSuccessResetsConsecutivePtoCount.
- New triggerPathMigrationBeforeHandshakeReturnsNotConnected.
- PathValidatorTest:
validationTimeoutAfter3PtoTransitionsToFailedAndRetiresFailedCid
now asserts the failed sequence is queued for retire.
- retirePriorToRegressionIsRejected → renamed to
retirePriorToRegressionIsClampedNotRejected.
All :quic:jvmTest and :nestsClient:jvmTest pass.
https://claude.ai/code/session_01PVVhSQXvw4K4oQ46FzpgaT
|
||
|
|
435c49bae9 |
feat(quic): client-initiated path validation + DCID rotation (RFC 9000 §9)
Implements the client side of connection migration so a path that
stops receiving ACKs (NAT rebind, route flap, dead peer) can be
recovered without a fresh handshake:
1. NEW_CONNECTION_ID frames from the server are stored in a
PathValidator pool (was: parsed and dropped).
2. After PATH_PROBE_PTO_THRESHOLD consecutive PTOs, the driver
calls triggerPathMigrationLocked(); the validator picks an
unused CID and queues a PATH_CHALLENGE with a CSPRNG payload.
3. The writer drains the challenge into the next outbound 1-RTT
packet using the new DCID; a RecoveryToken.PathChallenge is
attached so loss recovery can re-queue on packet drop.
4. Inbound PATH_RESPONSE that byte-equals the outstanding payload
promotes destinationConnectionId to the new bytes and queues
RETIRE_CONNECTION_ID for the prior sequence.
5. RFC 9000 §8.2.4: validation is abandoned after 3 * PTO;
timeout transitions to PathValidationState.Failed for retry.
Spec coverage:
- §5.1.1 initial DCID is sequence 0
- §5.1.2 retire_prior_to enforcement (clamping per §19.15
reordering rule, force-retire of cached entries below
watermark)
- §8.2.2 byte-equal payload match
- §8.2.4 3 * PTO abandonment
- §19.15 frame-encoding error checks (retire_prior_to >
sequence_number, invalid CID/token length)
- §19.16 RETIRE_CONNECTION_ID frame codec + protocol-violation
close on retire of an unissued sequence
Observability: QlogObserver gains onPathValidationStarted /
Succeeded / Failed and onConnectionIdActivated / Retired hooks
for qvis sequence diagrams.
Tests: PathValidatorTest (state-machine unit) +
ClientPathMigrationTest (full round-trip through InMemoryQuicPipe:
NEW_CONNECTION_ID -> trigger -> PATH_CHALLENGE -> PATH_RESPONSE ->
DCID rotated + RETIRE_CONNECTION_ID emitted). Existing
PathValidationTest (peer-initiated PATH_CHALLENGE echo) continues
to pass unchanged.
https://claude.ai/code/session_01PVVhSQXvw4K4oQ46FzpgaT
|
||
|
|
48b48a8481 |
fix(quic-interop): summarize-matrix picks newest run dir, not sibling .stdout.log
`ls -1dt run-*` returns both the per-run directories AND the
`.stdout.log` files run-matrix.sh tees alongside them, interleaved by
mtime. `head -n 1` could land on a `.stdout.log` regular file, after
which the rest of the script trying to walk subdirectories under it
silently produced empty output ("no <pair> dir under …"). Walk the
listing instead and pick the first entry that is actually a directory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
5e3e5cc5a0 |
chore(quic-interop): auto-patch upstream certs.sh for macOS BSD tr
`LC_CTYPE=C tr -dc '[:alnum:]' </dev/urandom` in the upstream runner's certs.sh trips "tr: Illegal byte sequence" on macOS — LC_CTYPE alone doesn't override the runtime locale chain. Only the amplificationlimit testcase exercises this loop (chain length > 1 → fakedns SAN inflation), but if it aborts the runner stops the whole matrix BEFORE any later test in TESTCASES_QUIC runs: handshakeloss, transferloss, handshakecorruption, transfercorruption, ipv6, v2, rebind-port, rebind-addr, connectionmigration, and the goodput/crosstraffic measurements all silently never execute. The post-mortem summary shows the 12 testcases that ran before the abort and looks deceptively complete. Add an idempotent `sed` step to run-matrix.sh that rewrites the line to `LC_ALL=C tr` on every invocation, plus a plan-file note so the next person hitting the deceptive summary doesn't repeat the diagnosis. The patch is a working-tree edit to ../quic-interop-runner/, not a fork; re-applied on every clone. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
5b8bd021b0 |
fix(quic): exempt retired stream ids from client-initiated squatting guard
The audit-4 #5 guard ran before the existing phantom-stream check, so an msquic-style aggressive STREAM retransmit on a stream we'd opened and retired (peer's loss-detector refire racing our FIN-ACK) closed the connection with STREAM_STATE_ERROR. Observed in the parallel `transfer` interop test where retransmits on retired streams 0/4 truncated whichever URL was still mid-receive (5 MB → 2.2 MB). Add `!isStreamIdRetiredLocked` to the guard so legitimate retransmits fall through to the existing silent-drop branch. Genuine squatting on never-opened CLIENT_* ids still closes — the existing FrameRoutingTest case stays green because id 0 is never put into the retired ring. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
9565411e3d | Extract UNSET_LABEL and ALPN_HQ_INTEROP constants in InteropClient |