fix(quic): discard Initial/Handshake keys per RFC 9001 §4.9
Pre-fix `:quic` held Initial AND Handshake encryption-level state indefinitely once derived. AEAD cipher state, per-level CRYPTO buffers, and the per-level sent-packet map all stayed alive for the lifetime of the connection — a real memory leak for long sessions (audio rooms run for hours). LevelState.discardKeys() (idempotent): - Nulls sendProtection / receiveProtection (frees AEAD state). - Replaces cryptoSend / cryptoReceive with empty instances. - Replaces ackTracker with an empty instance. - Clears sentPackets and resets largestAckedPn / largestAckedSentTimeMs. - Latches keysDiscarded = true. Hook locations: - Initial discard (RFC 9001 §4.9.1, client side): in QuicConnectionWriter.drainOutbound, after a Handshake-level packet is built into the outbound datagram. The next drainOutbound MUST NOT touch the Initial level; any retransmitted Initial from the peer is silently dropped (receiveProtection == null), which is correct per the same RFC since the server has also moved up encryption levels by then. - Handshake discard (RFC 9001 §4.9.2 + §4.1.2, client side): in QuicConnectionParser, on receipt of a HANDSHAKE_DONE frame. Once a level's protection is null, parser-side decrypt at that level returns null silently (existing receiveProtection == null check) and writer-side build skips it (existing sendProtection == null check), so no further code paths needed updating. New test: KeyDiscardTest (4 cases — Initial keys discarded after first Handshake packet, Handshake keys still live until HANDSHAKE_DONE, Handshake keys discarded on HANDSHAKE_DONE, discardKeys is idempotent). Listed in the audit-summary deferred-work as item 3 (`No Initial / Handshake key discard`). https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
This commit is contained in:
@@ -188,16 +188,21 @@ rooms don't exercise heavily:
|
||||
and RESET_STREAM / STOP_SENDING / NEW_CONNECTION_ID retransmit.
|
||||
See [`2026-05-04-control-frame-retransmit.md`](2026-05-04-control-frame-retransmit.md).
|
||||
2. ~~**`SendBuffer` doesn't retain bytes until ACK.**~~ Resolved with #1.
|
||||
3. **No Initial / Handshake key discard.** RFC 9000 §17.2.2 / RFC 9001 §4.9
|
||||
require dropping these after handshake completes; we hold them
|
||||
indefinitely. Memory leak per long session.
|
||||
3. ~~**No Initial / Handshake key discard.**~~ **Resolved 2026-05-05** —
|
||||
`LevelState.discardKeys()` nulls the AEAD protection, replaces the
|
||||
per-level CRYPTO buffers and ack-tracker with empty instances, and
|
||||
clears the sent-packet map. Initial keys discarded by the writer
|
||||
after the first Handshake packet is built (RFC 9001 §4.9.1);
|
||||
Handshake keys discarded by the parser on receipt of HANDSHAKE_DONE
|
||||
(RFC 9001 §4.9.2 + §4.1.2 client-side handshake confirmation).
|
||||
4. **No path validation for `NEW_CONNECTION_ID`.** We don't migrate.
|
||||
5. **Stateless reset detection.** Stateless-reset packets look like
|
||||
corruption to us.
|
||||
6. **`AckTracker.purgeBelow` threshold semantics.** Pre-existing bug:
|
||||
purges based on peer's largestAcknowledged of OUR outbound PNs, but
|
||||
purges OUR inbound PN tracker. Causes range-list bloat, not correctness
|
||||
failure.
|
||||
6. ~~**`AckTracker.purgeBelow` threshold semantics.**~~ **Resolved
|
||||
2026-05-05** — `RecoveryToken.Ack` now carries `(level, largestAcked)`;
|
||||
the writer captures these from the AckFrame at emit time, and
|
||||
`QuicConnection.onTokensAcked` purges the matching per-level inbound
|
||||
tracker on ACK-of-ACK. The wrong purge in the parser is gone.
|
||||
7. **Driver direct unit tests** require turning `UdpSocket` from `expect
|
||||
class` into an interface so the test side can stub. The driver is
|
||||
covered indirectly by every pipe-based test plus the live interop
|
||||
|
||||
Reference in New Issue
Block a user