docs(quic+nestsClient): post-implementation status + audio-rooms completion plan
Two new module-local plan docs (per CLAUDE.md's "plans live in the owning
module" rule) and a sweep of stale inline phase references.
quic/plans/2026-04-26-quic-stack-status.md:
Post-mortem of the original docs/plans/2026-04-22 plan. Documents
what shipped vs what was estimated, the actual package layout (~8.5k
LoC, 39 test files, 5 audit rounds), the crypto delegation surface
(Quartz only — no BouncyCastle, no JNI), interop verification status
(aioquic + picoquic; nests not yet), and known deferred items
(STREAM retransmit, Initial-key discard, etc.).
nestsClient/plans/2026-04-26-audio-rooms-completion.md:
Punch list to ship audio rooms end-to-end:
M1 Listener wire-up in Amethyst UI
M2 Multi-speaker audience UX
M3 Foreground service for backgrounded playback
M4 Manual interop pass against nostrnests.com
M5 MoQ publisher path (ANNOUNCE / TrackPublisher)
M6 Capture → encode → publish pipeline
M7 NestsSpeaker API
M8 App polish (reconnect, leave cleanup)
M9 Foreground service for speakers
~6 weeks for full audio rooms; ~2 weeks for listener-only MVP.
Inline doc cleanup:
* Removed "Phase 3a/3c-1/3c-2/3c-3" / "Phase B/C/D-K/L" references
from active code; replaced with "today" or pointers to the
completion plan
* Removed "Kwik-based stub" references; QuicWebTransportFactory and
surrounding docs now describe :quic as the production path
* TlsClient header reflects non-null certificateValidator + the
JdkCertificateValidator / PermissiveCertificateValidator split
* SendBuffer header documents the best-effort no-retransmit mode
explicitly (was hidden behind a "Phase L will fix this" note)
* MoqMessage / MoqObject / MoqSession reflect listener-side as
shipped + publisher-side as Phase M5
CLAUDE.md:
* Module list now includes :quic and :nestsClient (was 5 modules,
now 7)
* Architecture diagram + sharing philosophy explain what each new
module owns
No production behaviour changes; doc + comment-only edits. Tests green.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
# QUIC + WebTransport stack — current state (2026-04-26)
|
||||
|
||||
This document is the post-implementation snapshot of `:quic`. It supersedes
|
||||
the original [pure-kotlin QUIC + WebTransport plan](../../docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md)
|
||||
which was written before any code shipped and is now historical.
|
||||
|
||||
## TL;DR
|
||||
|
||||
`:quic` is a self-contained Kotlin-Multiplatform module that speaks QUIC v1
|
||||
+ HTTP/3 + WebTransport against real-world servers (aioquic + picoquic
|
||||
verified; nestsClient/MoQ on top). It uses Quartz crypto primitives only —
|
||||
no BouncyCastle, no JNI. ~8.5k lines of production code, ~5k lines of
|
||||
tests, 39 test files, five rounds of parallel audit + fix passes.
|
||||
|
||||
## What shipped vs the original plan
|
||||
|
||||
| Phase | Original estimate | Actual | Notes |
|
||||
|---|---|---|---|
|
||||
| A. Foundations | 1 wk | done | KMP module, UdpSocket on `jvmAndroid`, Varint migrated from nestsClient |
|
||||
| B. TLS 1.3 | 3 wk | done | RFC 8446 client state machine, X25519 ECDHE, RFC 8448 §3 vectors pass bit-for-bit |
|
||||
| C. Initial + Handshake packets | 2 wk | done | RFC 9001 §A.2/§A.3 vectors pass; ChaCha20 per §A.5 |
|
||||
| D. 1-RTT + STREAM | 1 wk | done | Stream offset reassembly, FIN, fuzzed |
|
||||
| E. ACK + flow control | 1 wk | done | MAX_DATA / MAX_STREAM_DATA / MAX_STREAMS routing + writer enforcement |
|
||||
| F. Loss recovery + congestion control | 1 wk | partial | PTO timer for handshake retries; **no retransmit-on-loss in steady state** (out of scope — see "deferred" below) |
|
||||
| G. Datagram extension | ½ wk | done | RFC 9221 frames + bounded incoming queue |
|
||||
| H. Connection lifecycle | 1 wk | done | CONNECTION_CLOSE, idle timeout, draining/closing, idempotent driver close |
|
||||
| I. HTTP/3 | 2 wk | done | Control stream + SETTINGS + GOAWAY (with id-regression check) + duplicate-id rejection |
|
||||
| J. QPACK | 2 wk | done | Static-table + Huffman + integer codec (RFC 7541 §B + RFC 9204 §B.1 vectors) |
|
||||
| K. Extended CONNECT + WT | 1 wk | done | Stream-type prefixes, HTTP Datagram + WT_CLOSE_SESSION capsule |
|
||||
| L. Interop + hardening | 2 wk | done + much more | Live interop against aioquic Docker; **5 rounds of audit + fix** beyond the plan |
|
||||
|
||||
The original plan estimated 17–19 weeks. We ran the full sequence plus five
|
||||
unscheduled audit rounds. Every audit found real bugs; the suite is what
|
||||
caught them on regression.
|
||||
|
||||
## What's actually in the module
|
||||
|
||||
```
|
||||
quic/
|
||||
├── plans/ ← module-local design docs
|
||||
└── src/
|
||||
├── commonMain/kotlin/com/vitorpamplona/quic/
|
||||
│ ├── Buffer.kt ← QuicReader / QuicWriter
|
||||
│ ├── Varint.kt ← RFC 9000 §16
|
||||
│ ├── connection/ ← QuicConnection orchestrator + Driver
|
||||
│ │ ├── QuicConnection.kt (≈ 600 lines, the hub)
|
||||
│ │ ├── QuicConnectionDriver.kt (read/send loops + close)
|
||||
│ │ ├── QuicConnectionParser.kt (feedDatagram + dispatchFrames)
|
||||
│ │ ├── QuicConnectionWriter.kt (drainOutbound + flow-control updates)
|
||||
│ │ ├── PacketProtection.kt + builder
|
||||
│ │ ├── PacketNumberSpace.kt
|
||||
│ │ ├── ConnectionId.kt + TransportParameters.kt
|
||||
│ │ └── EncryptionLevel.kt + LevelState.kt
|
||||
│ ├── crypto/ ← Aead, header protection, HKDF helpers, AesEcbHeaderProtection,
|
||||
│ │ ChaCha20HeaderProtection, ChaCha20Poly1305Aead, InitialSecrets,
|
||||
│ │ PlatformAesOneBlock, PlatformChaCha20Block (expect),
|
||||
│ │ bestAes128GcmAead (expect)
|
||||
│ ├── frame/ ← Frame.kt sealed hierarchy + FrameFuzzerTest target
|
||||
│ │ includes RESET_STREAM / STOP_SENDING / NEW_TOKEN
|
||||
│ ├── http3/ ← Http3FrameReader + Http3Settings + frame types
|
||||
│ ├── packet/ ← LongHeaderPacket, ShortHeaderPacket, RetryPacket, peekHeader
|
||||
│ ├── qpack/ ← QpackDecoder, QpackEncoder, QpackHuffman, QpackInteger,
|
||||
│ │ QpackStaticTable
|
||||
│ ├── recovery/ ← AckTracker (with ack-eliciting gating)
|
||||
│ ├── stream/ ← QuicStream, ReceiveBuffer (with FIN-fully-read), SendBuffer, StreamId
|
||||
│ ├── tls/ ← TlsClient state machine + ClientHello/ServerHello/EE/Cert/CV/Finished
|
||||
│ │ codecs, TlsKeySchedule, TlsTranscriptHash (incremental), TlsConstants,
|
||||
│ │ PermissiveCertificateValidator, TlsRunningSha256 (expect)
|
||||
│ ├── transport/ ← UdpSocket (expect)
|
||||
│ └── webtransport/ ← QuicWebTransportFactory + QuicWebTransportSessionState +
|
||||
│ WtPeerStreamDemux + WtCapsule + WtDatagram + ExtendedConnect
|
||||
└── jvmAndroid/kotlin/com/vitorpamplona/quic/
|
||||
├── crypto/JcaAesGcmAead.kt ← cached JCA Cipher per direction with IV-reuse fallback
|
||||
├── crypto/PlatformCrypto.kt ← actuals
|
||||
├── tls/JdkCertificateValidator.kt ← system-trust-store chain validation + RSA-PSS / ECDSA / Ed25519
|
||||
├── tls/TlsRunningSha256.kt ← MessageDigest.clone()-based incremental hash
|
||||
└── transport/UdpSocket.kt ← DatagramChannel + suspend wrapper
|
||||
```
|
||||
|
||||
## Crypto surface
|
||||
|
||||
Quartz primitives only:
|
||||
|
||||
| Primitive | Source |
|
||||
|---|---|
|
||||
| AES-128-GCM | `JcaAesGcmAead` (jvmAndroid) — cached `Cipher` per direction; `Aes128Gcm` singleton (commonMain) for non-hot paths |
|
||||
| ChaCha20-Poly1305 | Quartz `ChaCha20Poly1305` (commonMain pure-Kotlin) wrapped in `ChaCha20Poly1305Aead` |
|
||||
| HKDF-Extract / Expand-Label | Quartz `Hkdf` + `MacInstance`; thin RFC 8446 §7.1 helper in `crypto/HkdfHelpers.kt` |
|
||||
| SHA-256 (one-shot) | Quartz `sha256(...)` |
|
||||
| SHA-256 (incremental, for transcript) | `TlsRunningSha256` (expect/actual; jvmAndroid wraps `MessageDigest.clone()`) |
|
||||
| X25519 ECDHE | Quartz `X25519` |
|
||||
| Ed25519 | Quartz `Ed25519` (only inside the JVM cert validator path) |
|
||||
| AES-ECB (one block, for header protection) | `Cipher.getInstance("AES/ECB/NoPadding")` (jvmAndroid only) |
|
||||
| ChaCha20 keystream (header protection) | Quartz `ChaCha20Core.chaCha20Xor` |
|
||||
| SecureRandom | Quartz `RandomInstance` |
|
||||
|
||||
X.509 chain validation, hostname verification, signature verification all
|
||||
delegate to JDK `TrustManagerFactory` / `Signature.getInstance(...)`.
|
||||
`CertificateValidator` is a non-null typed parameter — tests pass an
|
||||
explicit `PermissiveCertificateValidator`; production passes
|
||||
`JdkCertificateValidator`.
|
||||
|
||||
## What we deliberately don't do
|
||||
|
||||
- **QUIC server role.** Client-only.
|
||||
- **0-RTT / session resumption.** No PSK extension offered; an arriving
|
||||
ServerFinished without prior Certificate is hard-failed.
|
||||
- **Connection migration / preferred address / multiple paths.** `NEW_CONNECTION_ID`
|
||||
and `PATH_*` frames decode (so peers don't break us) but aren't acted on.
|
||||
- **Path MTU discovery.** Fixed 1200-byte ceiling per RFC 9000 §14.
|
||||
- **HTTP/3 server push.**
|
||||
- **QPACK dynamic-table inserts on the encoder.** We send literal-only;
|
||||
decoder accepts dynamic-table indexed lines.
|
||||
- **ECN / anti-amplification limits.** We're a client.
|
||||
- **Retransmit-on-loss in steady state.** `SendBuffer.takeChunk` releases
|
||||
bytes to the wire and doesn't retain them. The handshake survives via the
|
||||
`Driver.sendLoop` PTO path which re-pulls from CRYPTO send buffers; for
|
||||
STREAM data, a real loss event truncates the stream silently. This is
|
||||
acceptable for MoQ (DATAGRAM-mode audio, plus stream usage is
|
||||
control-plane only) but would be the first item to add for general use.
|
||||
- **TLS Key-Update / NewSessionTicket.** Detected and refused (KeyUpdate
|
||||
fails the connection rather than silently desynchronising).
|
||||
|
||||
## Verified interop
|
||||
|
||||
- **aioquic** (Python): `quic-interop-runner`-style Docker setup; full
|
||||
handshake + Extended CONNECT + h3 datagram round-trip.
|
||||
- **picoquic** (C): Docker image, lightweight HTTP/3 GET.
|
||||
- **In-memory pipe** (`InMemoryQuicPipe`, modeled on Cloudflare quiche's
|
||||
`Pipe`) drives both sides of the handshake in one JVM for fast tests
|
||||
without sockets.
|
||||
|
||||
What's NOT verified: a live nests/MoQ audio-room exchange end to end. That
|
||||
gates on the audio-rooms completion plan
|
||||
([nestsClient/plans/2026-04-26-audio-rooms-completion.md](../../nestsClient/plans/2026-04-26-audio-rooms-completion.md)).
|
||||
|
||||
## Audit summary
|
||||
|
||||
| Round | Focus | Findings | Status |
|
||||
|---|---|---|---|
|
||||
| 1 | Initial review (pre-interop) | 6 critical correctness/security bugs | all fixed |
|
||||
| 2 | TLS hardening + lifecycle | hangs + TLS edge cases | all fixed |
|
||||
| 3 | Performance + concurrency | cipher caching, polling, transcript O(n²) | all fixed |
|
||||
| 4 | Core + TLS + perf + coverage gaps (4 parallel agents) | ~30 items including 4 CRITICAL interop blockers | all fixed; comprehensive regression tests added |
|
||||
| 5 | Regression check + concurrency-specific (2 parallel agents) | 1 CRITICAL ackEliciting regression I'd just introduced + WT scope leak + others | all fixed |
|
||||
|
||||
Every fix carries an inline `audit-N #M` reference comment so the regression
|
||||
test → fix → comment chain is auditable. The whole audit corpus is in the
|
||||
git log (commits whose subject starts with `fix(quic):` or `perf(quic):`).
|
||||
|
||||
## Test inventory
|
||||
|
||||
Roughly grouped:
|
||||
|
||||
- **RFC vectors:** RFC 8448 §3 (TLS handshake), RFC 9001 §A.1–A.5 (Initial
|
||||
encrypt/decrypt, Retry, ChaCha20, server-side HP), RFC 9204 §B.1 (QPACK),
|
||||
RFC 7541 (Huffman).
|
||||
- **End-to-end pipe tests:** `InMemoryQuicPipeTest`, `CoalescedPacketSkipTest`,
|
||||
`ReceiveLimitEnforcementTest`, `PeerStreamLimitTest`, `FrameRoutingTest`,
|
||||
`AckElicitingFramesTest`.
|
||||
- **Adversarial:** `FrameFuzzerTest`, `HostilePacketInputTest`,
|
||||
`TlsSecurityPropertiesTest`, `HelloRetryRequestTest`.
|
||||
- **Crypto:** `JcaAesGcmAeadTest`, `ChaCha20Poly1305AeadTest`,
|
||||
`TlsTranscriptHashTest`.
|
||||
- **WT / HTTP/3:** `CapsuleReaderTest`, `WtPeerStreamDemuxTest`,
|
||||
`WtFramingTest`.
|
||||
- **Recovery:** `AckTrackerCoalescedTest`, `AckTrackerGatingTest`.
|
||||
- **Interop:** `InteropRunner` (jvmTest, drives a real socket against a
|
||||
Dockerised aioquic; opt-in, not in CI).
|
||||
|
||||
## Known limitations / deferred work
|
||||
|
||||
These are the items future audit rounds keep flagging that we've
|
||||
consciously not tackled — all confined to the steady-state path that audio
|
||||
rooms don't exercise heavily:
|
||||
|
||||
1. **No STREAM retransmit on loss** (audit-4 #10). Acceptable for MoQ
|
||||
datagram audio; would block any heavy stream-based use. ~1 wk to add.
|
||||
2. **`SendBuffer` doesn't retain bytes until ACK.** Same scope as #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.
|
||||
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.
|
||||
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
|
||||
runner.
|
||||
|
||||
## Pointers
|
||||
|
||||
- Original (frozen) plan: `docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md`
|
||||
- Audio-rooms NIP draft: `docs/plans/2026-04-22-nip-audio-rooms-draft.md`
|
||||
- Completion plan: `nestsClient/plans/2026-04-26-audio-rooms-completion.md`
|
||||
- Live interop runner: `quic/src/jvmTest/.../interop/InteropRunner.kt`
|
||||
- Audit history: `git log --grep='audit' -- quic/`
|
||||
+5
-2
@@ -33,8 +33,11 @@ import com.vitorpamplona.quic.tls.deriveQuicKeys
|
||||
* TLS traffic secret + TLS cipher-suite identifier. The QUIC labels in
|
||||
* RFC 9001 §5.1 (`quic key`, `quic iv`, `quic hp`) drive the expansion.
|
||||
*
|
||||
* For Phase B–K we only support TLS_AES_128_GCM_SHA256 (16/12/16) and
|
||||
* TLS_CHACHA20_POLY1305_SHA256 — the SHA-256 suites; nests speaks both.
|
||||
* Supports the two SHA-256-keyed cipher suites required for QUIC v1
|
||||
* interop with nests / aioquic / picoquic: TLS_AES_128_GCM_SHA256
|
||||
* (16/12/16) and TLS_CHACHA20_POLY1305_SHA256. AES-256-GCM-SHA384 is
|
||||
* omitted — Quartz's primitives don't ship SHA-384 and no interop target
|
||||
* we've encountered requires it.
|
||||
*/
|
||||
fun packetProtectionFromSecret(
|
||||
cipherSuite: Int,
|
||||
|
||||
@@ -27,14 +27,16 @@ package com.vitorpamplona.quic.stream
|
||||
* Application code [enqueue]s payload bytes; the connection's send loop
|
||||
* [takeChunk]s as much as it can fit in the next packet, given the
|
||||
* remaining packet budget and stream-level / connection-level flow control
|
||||
* credit. Sent bytes stay in the buffer until [acknowledge] (Phase F adds
|
||||
* retransmission of lost bytes; for v1 we just trust the receiver and
|
||||
* release on send).
|
||||
* credit.
|
||||
*
|
||||
* For Phase D-K we run a "best effort" mode: bytes are released from the
|
||||
* **Best-effort mode (no STREAM retransmit):** bytes are released from the
|
||||
* buffer the moment they're handed off, on the assumption that the
|
||||
* underlying network is stable. Phase L adds retransmit-on-loss (using the
|
||||
* same send buffer that retains until ACK).
|
||||
* underlying network is stable. A real loss event silently truncates the
|
||||
* stream. Acceptable for MoQ over QUIC (audio rooms use OBJECT_DATAGRAM,
|
||||
* which is loss-tolerant; STREAM is control-plane only). See the deferred
|
||||
* items in `quic/plans/2026-04-26-quic-stack-status.md` — adding
|
||||
* retain-until-ACK + retransmit is the first thing to add for general
|
||||
* STREAM-heavy use.
|
||||
*/
|
||||
class SendBuffer {
|
||||
/**
|
||||
|
||||
@@ -41,10 +41,12 @@ import com.vitorpamplona.quic.QuicWriter
|
||||
* 2. After ServerHello arrives → install Handshake keys both directions.
|
||||
* 3. After server Finished decoded → install 1-RTT (application) keys both directions.
|
||||
*
|
||||
* For Phase B we **do not yet validate the certificate chain or the
|
||||
* CertificateVerify signature**. That's wired in during Phase C/L when we
|
||||
* have a real server to talk to. We DO compute and verify the server
|
||||
* Finished MAC.
|
||||
* Certificate chain validation + CertificateVerify signature verification
|
||||
* are delegated to [certificateValidator] (`JdkCertificateValidator` in
|
||||
* production; `PermissiveCertificateValidator` for in-process tests). The
|
||||
* validator parameter is non-null — passing `null` was a silent-MITM
|
||||
* hazard and was removed in round-4 of the audit. We also compute and
|
||||
* verify the server Finished MAC ourselves.
|
||||
*/
|
||||
class TlsClient(
|
||||
val serverName: String,
|
||||
|
||||
Reference in New Issue
Block a user