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:
Claude
2026-05-05 01:41:28 +00:00
parent e3a3ffd1d9
commit 2053f50f35
5 changed files with 237 additions and 31 deletions
+12 -7
View File
@@ -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