Files
amethyst/docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md
T
Claude 2b44e9b06b docs(quic-plan): lock in new top-level :quic module (Option A)
Updates the Architecture section to reflect the decision to house the
pure-Kotlin QUIC + HTTP/3 + WebTransport code in a new top-level
Gradle module `:quic`, sibling to `:quartz`, `:commons`, `:nestsClient`.

Key changes:

- Module placement: new KMP module `:quic` with commonMain +
  jvmAndroid + jvmMain + androidMain source sets (mirrors Quartz).
  `:quic` takes `api(project(":quartz"))` so all crypto primitives
  are in-scope without re-export.
- settings.gradle delta spelled out.
- Package layout shifts from
  `nestsClient/src/jvmAndroid/...transport/quic/` to
  `:quic/src/commonMain/com/vitorpamplona/quic/`, with UDP
  socket-specific bits in `jvmAndroid/`.
- Varint.kt migration called out: moves from
  `nestsClient/moq/Varint.kt` to `:quic` at
  `com.vitorpamplona.quic.Varint`. Mechanical, one commit.
- Rename KwikWebTransportFactory stub → QuicWebTransportFactory
  (real), living in `:quic` but implementing `:nestsClient`'s
  existing `WebTransportFactory` interface. AudioRoomConnectionViewModel
  changes one ctor call.
- Rationale section documenting why Option A beats putting it in
  `:nestsClient` (single responsibility / reusability / security
  boundary / test isolation / build graph / charter fit).
- Phase A now explicitly includes the module-creation +
  Varint-migration step; test suite must stay green across the move.

Timeline unchanged (17-19 weeks). Dependency story unchanged
(no external libs; everything piggybacks on Quartz).

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 13:20:33 +00:00

385 lines
20 KiB
Markdown

# Pure-Kotlin QUIC + WebTransport plan
## Context
The Audio Rooms feature (NIP-53 kind 30312) is wire-incompatible with anything but
WebTransport over QUIC, because that's what the nostrnests/nests MoQ relay speaks.
Phases 3a / 3c / 3d of the rollout shipped the entire Kotlin stack from NIP-98 auth
through MoQ framing through Opus playback — every layer except the WebTransport
factory itself, which is a stub that throws `WebTransportException(NotImplemented)`.
We tried the easy path (depend on a Java QUIC library) and exhausted it:
| Library | Outcome |
|---|---|
| `tech.kwik:kwik-core`, `tech.kwik:kwik`, `tech.kwik:flupke` | Maven Central returns `Could not find` for every coord/version we tried. |
| `io.netty.incubator:netty-incubator-codec-http3` | Resolves, **but** it depends on `netty-incubator-codec-native-quic` (JNI to Cloudflare quiche) which has no Android-compatible native classifier published. Compile-time green, runtime `UnsatisfiedLinkError`. |
| Cronet | QUIC support is internal; WebTransport not exposed in any stable public API. |
| WebView JavaScript bridge | Rejected by product. |
So the path forward is a **pure-Kotlin QUIC client** with just enough WebTransport
on top to interop with nests. This plan scopes that work honestly.
## Scope
### What we build in pure Kotlin
- QUIC v1 client per RFC 9000 (no server, no version negotiation beyond v1).
- QUIC-TLS binding per RFC 9001 (CRYPTO-frame splicing, per-level keys, header
protection).
- QUIC loss recovery + congestion control per RFC 9002 (NewReno minimum).
- QUIC datagram extension per RFC 9221.
- HTTP/3 client subset per RFC 9114 (control stream, SETTINGS, request streams).
- QPACK encoder per RFC 9204 (literal-only emit; full decoder).
- Extended CONNECT (RFC 8441 / WT-H3 draft-13) for `:protocol = webtransport`.
- WebTransport over HTTP/3 framing: stream-type prefix bytes, capsule protocol
for `WT_SESSION_CLOSE`.
- HTTP Datagrams per RFC 9297 carrying WT datagrams.
### What we delegate
- **TLS 1.3 AEAD ciphers** → Quartz's existing `AESGCM` (common, with AAD
support) + `ChaCha20Poly1305` (common, pure-Kotlin).
- **HKDF primitives** → Quartz's existing `Hkdf` class + its `MacInstance`
(HMAC-SHA256). We add one thin helper — HKDF-Expand-Label from RFC 8446 §7.1
— on top, plus a general `hkdfExpand(prk, info, length)` since Quartz only
ships NIP-44's specialised `fastExpand`.
- **Key agreement** → Quartz's existing `X25519` (common expect/actual).
- **SHA-256 hashing** → Quartz's existing `Sha256` (common expect/actual).
- **Cert chain signature verification** → Quartz's `Ed25519` where the leaf
uses Ed25519; JDK `Signature.getInstance("SHA256withRSA")` /
`"SHA256withECDSA"` for the common cases (nests typically uses Let's Encrypt
ECDSA certs).
- **X.509 parsing + chain validation** → JDK `CertificateFactory` +
`TrustManagerFactory.getInstance(...).init(null as KeyStore?)` (Android
system trust store).
- **AES-ECB for QUIC header protection** (one block, 5-byte sample) → JDK
`Cipher.getInstance("AES/ECB/NoPadding")` — trivial, no dep.
- **SecureRandom** → Quartz's `RandomInstance` / `SecureRandom`.
**No BouncyCastle dependency.** The QUIC-TLS cryptographic surface is already
fully covered by Quartz's primitives — nip44Encryption/crypto (AEAD + HKDF +
ChaCha20 + Poly1305), marmot/mls/crypto (X25519 + Ed25519 + Curve25519Field),
utils/ciphers/AESGCM, utils/mac, utils/sha256, utils/SecureRandom. We write
the TLS 1.3 client state machine ourselves using those primitives.
### Explicitly out of scope
- QUIC server role.
- 0-RTT / session tickets (defer until needed).
- Connection migration / preferred address.
- Multiple concurrent paths.
- Path MTU discovery (assume 1200-byte safe ceiling).
- HTTP/3 server push.
- QPACK dynamic table on the encoder side (use literal-only headers; still decode
dynamic table from the peer).
- ECN-based congestion signalling.
- Anti-amplification limits (we're a client; rarely matters).
## Architecture
**Module placement:** a new top-level Gradle module `:quic` sibling to
`:quartz`, `:commons`, `:nestsClient`. It's a KMP module (commonMain + an
`androidMain` + `jvmMain` pair via a shared `jvmAndroid` source set, mirroring
Quartz's pattern) so it's reusable for future non-Nests consumers
(Nostr-over-QUIC relays, desktop audio, iOS later). `:quic` takes
`api(project(":quartz"))` so the crypto primitives are available without
re-exporting. `:nestsClient` drops its `transport/` subpackage and declares
`implementation(project(":quic"))` instead.
**settings.gradle delta:**
```groovy
include ':quartz'
include ':commons'
include ':ammolite'
include ':quic' // NEW — pure-Kotlin QUIC + HTTP/3 + WebTransport
include ':nestsClient' // gains `implementation project(':quic')`
include ':desktopApp'
include ':cli'
```
**Package layout under `:quic`:**
```
quic/
├── build.gradle.kts ← KMP module, api(project(":quartz"))
└── src/
├── commonMain/kotlin/com/vitorpamplona/quic/
│ ├── Varint.kt ← migrated from nestsClient/moq/Varint.kt
│ ├── crypto/ ← HkdfExpandLabel + thin helpers
│ ├── tls/ ← TLS 1.3 client state machine
│ ├── packet/ ← long/short-header codec + protection
│ ├── frame/ ← CRYPTO, STREAM, ACK, MAX_*, CONNECTION_CLOSE…
│ ├── stream/ ← per-stream buffers + flow control
│ ├── recovery/ ← loss detection + PTO + NewReno
│ ├── connection/ ← QuicConnection state machine
│ ├── http3/ ← RFC 9114 client
│ ├── qpack/ ← RFC 9204 encoder/decoder
│ └── webtransport/
│ ├── ExtendedConnect.kt
│ ├── WtStreamType.kt ← 0x41 (bidi) / 0x54 (uni)
│ ├── WtCapsule.kt ← WT_SESSION_CLOSE = 0x2843
│ └── QuicWebTransportFactory.kt ← implements nestsClient's WebTransportFactory
├── jvmAndroid/kotlin/com/vitorpamplona/quic/
│ └── transport/
│ └── UdpSocket.kt ← DatagramChannel + suspend wrapper
├── commonTest/
└── jvmTest/ ← RFC 8448 / RFC 9001 vector tests
```
**Migrations when `:quic` lands:**
- `nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/Varint.kt`
moves to `:quic` at `com.vitorpamplona.quic.Varint`. The existing MoQ codec
(Phase 3c) imports the new path. Mechanical change — one commit.
- The `nestsClient/transport/` package (`WebTransportSession` +
`FakeWebTransport` + `KwikWebTransportFactory` stub) stays in `:nestsClient`
because that's the seam `:nestsClient`'s audio pipeline talks to. The real
implementation `QuicWebTransportFactory` lives in `:quic` and implements
`nestsClient.transport.WebTransportFactory``:nestsClient` swaps its
constructor call from `KwikWebTransportFactory()` to
`QuicWebTransportFactory()`, nothing else changes upstream.
**Why a dedicated module (Option A):**
- **Single responsibility.** `:quic` is a transport library; `:nestsClient`
is an audio-room client. They have nothing conceptually in common beyond
nests happens to run over WebTransport.
- **Reusability.** Future Nostr-over-QUIC relay work, any Kotlin project that
wants MoQ, desktop / iOS targets all pull `:quic` directly. Potentially
extractable to an open-source library later — pure-Kotlin QUIC barely
exists in the JVM ecosystem, so this would be genuinely valuable beyond
Amethyst.
- **Security boundary.** QUIC + TLS is the code you want smallest, isolated,
and auditable without reading audio-room code around it. A dedicated module
enforces the boundary.
- **Test isolation.** `:quic` tests run on `commonTest` / `jvmTest` — fast
feedback. They never touch `:nestsClient`'s fakes.
- **Build graph.** `:quic` has no Android-framework dependencies (no
`MediaCodec`, no `AudioTrack`), so it compiles clean as a pure KMP lib and
targets iOS / desktop trivially when those arrive.
- **Charter.** Quartz is labeled "Nostr protocol only, no UI" in the project's
CLAUDE.md. QUIC isn't Nostr; it doesn't fit Quartz's charter.
Naming note: the existing `KwikWebTransportFactory.kt` stub in `:nestsClient`
is renamed to reference the real implementation `QuicWebTransportFactory` in
`:quic`. `AudioRoomConnectionViewModel` updates its constructor call in one
line, no protocol impact.
## Phase breakdown (one developer, full-time estimates)
Each phase ends with green commonTest tests that exercise its layer in isolation,
plus an integration test against the next layer up.
### Phase A — Foundations (1 week)
- Create new `:quic` module (KMP, `api(project(":quartz"))`), register in
`settings.gradle`, port `Varint.kt` from `:nestsClient` and update MoQ
imports, add `:nestsClient``implementation(project(":quic"))` wire.
- UDP socket abstraction (`DatagramChannel`, suspend wrapper, single-receive
loop) in `jvmAndroid`.
- Connection ID generation (cryptographically random, 8-byte source IDs).
- Packet number space tracking (Initial, Handshake, Application).
- Largest-acked / next-pn tracking per space.
- **Tests:** varint tests migrate with the file; add CID + packet-number-space
unit tests. Full MoQ + nestsClient test suite stays green after the
Varint migration.
### Phase B — TLS 1.3 client state machine (3 weeks)
Built entirely on Quartz primitives; no BouncyCastle.
- `HkdfExpandLabel` helper per RFC 8446 §7.1 on top of Quartz's `Hkdf.extract`
+ a new `hkdfExpand(prk, info, length)` written against `MacInstance`.
- `TlsKeySchedule` — derive Early / Handshake / Master secrets + per-direction
traffic secrets per RFC 8446 §7.1.
- `TlsTranscriptHash` — running SHA-256 over the handshake messages using
Quartz's `Sha256`.
- `TlsCipherSuite` — the three QUIC-mandatory suites: TLS_AES_128_GCM_SHA256
(uses Quartz `AESGCM`), TLS_CHACHA20_POLY1305_SHA256 (uses Quartz
`ChaCha20Poly1305`), and optionally TLS_AES_256_GCM_SHA384 (SHA-384 is the
only primitive not yet in Quartz; skip this suite for v1).
- `TlsKeyExchange` — X25519 via Quartz `X25519.generateKeyPair()` +
`X25519.dh()`.
- `TlsClientHello` / `TlsServerHello` / `TlsEncryptedExtensions` /
`TlsCertificate` / `TlsCertificateVerify` / `TlsFinished` encode/decode.
- QUIC transport parameters TLS extension (codec for the opaque blob carrying
`initial_max_data`, `max_idle_timeout`, `max_datagram_frame_size`, etc.).
- Certificate chain validation delegated to JDK `TrustManagerFactory`.
- `TlsClient` state machine: drive handshake by consuming CRYPTO-frame payload
bytes and producing outbound CRYPTO-frame payload bytes; expose per-
encryption-level secrets as they're derived so the QUIC layer can install
keys.
- **Tests:** RFC 8448 §3 (Simple 1-RTT Handshake) test vectors — bit-for-bit
reproduction of the Client's generated bytes given fixed randomness. In-
process handshake against a minimal test TLS server built with the same
primitives.
### Phase C — Initial + Handshake packets (2 weeks)
- Long-header packet codec (Type, Version, DCID, SCID, Token, Length, PN).
- Header protection (AES-ECB sample mask for AES suites, ChaCha20 for CC20).
- Payload protection via JCA AEAD; nonce = packet-number XOR static IV.
- Initial-secret derivation with the v1 salt (RFC 9001 §5.2).
- CRYPTO frame encode/decode with offset reassembly.
- **Tests:** decode the `Initial` packet from RFC 9001 Appendix A.2 (Cloudflare's
test vectors). Bit-for-bit match required.
### Phase D — 1-RTT + STREAM frames (1 week)
- Short-header packet codec.
- STREAM frame encode/decode with OFF/LEN/FIN bits.
- Per-stream receive buffer with out-of-order chunk reassembly.
- Per-stream send buffer with retransmit hold (until ACKed).
- **Tests:** stream offset reassembly against a property-based fuzz that injects
random reordering / duplication.
### Phase E — ACK + flow control (1 week)
- ACK frame encoding (ranges from packet-number tracking).
- Connection-level + stream-level send credits.
- MAX_DATA, MAX_STREAM_DATA, MAX_STREAMS handling.
- DATA_BLOCKED / STREAM_DATA_BLOCKED emission when blocked.
- **Tests:** unit tests for credit accounting + ACK range encoding edge cases.
### Phase F — Loss recovery + congestion control (1 week)
- PTO timer per RFC 9002 §6.
- RACK-based loss detection (packet-threshold + time-threshold).
- NewReno congestion controller (slow start, congestion avoidance, recovery).
- Pacing not strictly required for v1; optional time-spaced send.
- **Tests:** simulate packet loss at controlled rates; assert retransmissions
arrive within PTO + cwnd doesn't oscillate pathologically.
### Phase G — Datagram extension (2 days)
- DATAGRAM frame (frame types 0x30 / 0x31).
- Negotiate `max_datagram_frame_size` transport parameter.
- **Tests:** datagram round-trip against in-process test server.
### Phase H — Connection lifecycle (1 week)
- Transport parameters codec (~30 parameters; only ~10 we send).
- CONNECTION_CLOSE handling (transport vs. application-level).
- Idle timeout (default 30 s, both sides MIN).
- Draining + closing states per RFC 9000 §10.2.
- **Tests:** assert clean close round-trip; assert idle timeout fires.
**End of Phase H = working QUIC client.** ~9 weeks elapsed.
### Phase I — HTTP/3 (2 weeks)
- Unidirectional control stream (peer-direction opens with stream-type 0x00).
- SETTINGS frame (we MUST emit `SETTINGS_ENABLE_CONNECT_PROTOCOL = 1`,
`SETTINGS_ENABLE_WEBTRANSPORT = 1`, `SETTINGS_H3_DATAGRAM = 1`).
- HEADERS frame (carrying QPACK output).
- DATA frame.
- GOAWAY handling.
- **Tests:** SETTINGS round-trip; HEADERS+DATA request/response.
### Phase J — QPACK (2 weeks)
- Static table (RFC 9204 Appendix A).
- Encoder: literal-with-name-reference + literal-without-name-reference only.
No dynamic table inserts on our side. Simplifies massively; nests will accept
literals.
- Decoder: full dynamic table support so we can read what nests sends.
- Huffman codec (RFC 9204 Appendix B).
- Encoder + decoder streams (the two QPACK uni streams).
- **Tests:** decode known-good QPACK headers from the IETF interop corpus.
### Phase K — Extended CONNECT + WebTransport (1 week)
- HEADERS request with `:method=CONNECT`, `:protocol=webtransport`,
`:scheme=https`, `:authority=...`, `:path=...`.
- Response handling (2xx → session open, 4xx/5xx → ConnectRejected).
- Stream-type prefix bytes (0x41 client-bidi, 0x54 client-uni).
- Capsule protocol for graceful close (capsule type 0x2843 = WT_CLOSE_SESSION).
- HTTP Datagram (RFC 9297) wrapping WT datagrams with quarter-stream-id prefix.
- Wire into existing `WebTransportSession` interface.
- **Tests:** unit tests for capsule + datagram framing; integration test using
the in-process H3 server from Phase J.
### Phase L — Interop + hardening (2 weeks)
- Run against `nests-rs` locally. Fix every protocol mismatch.
- Run against `nostrnests.com` once local works.
- Fuzz testing: malformed packet acceptance, off-by-one frame lengths, etc.
- Performance pass: confirm < 10 ms median round-trip on local testing.
**End of Phase L = audible audio against real nests.** ~16 weeks elapsed.
## Validation strategy
1. **Unit tests per phase.** Already established pattern in nestsClient.
2. **In-process integration**, Phase B onward: spin up a BC-based QUIC server
in the same JVM and let client + server drive each other. No real network.
3. **`quic-interop-runner` corpus** (Phase L): the IETF QUIC WG publishes
reference test vectors for client behavior across every implementation. We
can replay their packet captures and assert correct decoding.
4. **Local nests-rs** (Phase L): run nests' Rust reference server in Docker;
point our client at it. Verifies real Extended CONNECT + MoQ on top.
5. **Production `nostrnests.com`** (Phase L): final confidence step.
## Risks + stop conditions
| Risk | Mitigation | Stop condition |
|---|---|---|
| Quartz's `Hkdf` only ships the NIP-44-specialised `fastExpand`, not a general-purpose expand | Write generic `hkdfExpand(prk, info, length)` in Phase B on top of `MacInstance`; upstream the helper back to Quartz `utils/` | Trivial — add one file |
| Quartz's `X25519` doesn't accept arbitrary-length secrets or has subtle incompatibilities with TLS 1.3's derivation | Verify in Phase B day-1 with a micro-test: `X25519.dh(fixed_priv, fixed_pub)` against RFC 7748 test vector | If mismatched, pin a specific Quartz version or fork the primitive |
| Header protection edge cases mis-encode 4-byte packet numbers | RFC 9001 has explicit test vectors | None — must work |
| QPACK dynamic table from nests is more aggressive than literal-only | Decoder supports full dynamic table; only encoder is literal-only | If nests rejects literal-only encoded headers, add minimal encoder dynamic table (~+1 week) |
| Loss recovery oscillates badly under real RTT variance | NewReno is conservative enough | If empirically bad, swap to BBRv1 implementation reference (~+2 weeks) |
| Server certs use RSA + SHA-256 / ECDSA P-256 / Ed25519 — we need all three sig verifiers | JDK `Signature.getInstance("SHA256withRSA")` + `Signature.getInstance("SHA256withECDSA")` cover RSA + ECDSA; Quartz `Ed25519` covers Ed25519 | None — all three are cheap |
| WebTransport draft mismatch with nests | Pin to whatever draft nests serves; advertise both legacy + current setting IDs | Hard fail back to documenting the version skew |
**Hard abandonment trigger:** if at end of Phase D (~6 weeks in) we cannot pass
the RFC 9001 Appendix A test vectors bit-for-bit, the implementation has a deep
bug we can't shake without dedicated cryptographic review. At that point the
honest call is to wait for an Android-compatible Java QUIC library to ship
elsewhere.
## Timeline summary
| Phase | Weeks | Cumulative |
|---|---|---|
| A. Foundations | 1 | 1 |
| B. TLS 1.3 client on Quartz primitives | 3 | 4 |
| C. Initial + Handshake packets | 2 | 6 |
| D. 1-RTT + STREAM | 1 | 7 |
| E. ACK + flow control | 1 | 8 |
| F. Loss + congestion | 1 | 9 |
| G. Datagrams | ½ | 9.5 |
| H. Connection lifecycle | 1 | 10.5 |
| I. HTTP/3 | 2 | 12.5 |
| J. QPACK | 2 | 14.5 |
| K. Extended CONNECT + WT | 1 | 15.5 |
| L. Interop + hardening | 2 | 17.5 |
**17-19 weeks of full-time work for one developer**, or 5-6 months at a
normal review-and-iteration cadence. TLS 1.3 is +1 week vs. the original
BC-adapter estimate because we write the state machine ourselves, but the
total is within the same band and we've eliminated the external-library
integration risk entirely. Add a security review of the QUIC + TLS + new
HKDF-Expand helper before shipping to users — minimum 2 weeks calendar
time, possibly external.
## Dependencies to add
**None.** Every cryptographic primitive lives in Quartz already:
| Primitive | Quartz location |
|---|---|
| AES-128-GCM with AAD | `utils/ciphers/AESGCM.kt` (commonMain expect, jvmAndroid actual via JCA) |
| ChaCha20-Poly1305 | `nip44Encryption/crypto/ChaCha20Poly1305.kt` (commonMain, pure Kotlin) |
| HKDF-Extract + MAC | `nip44Encryption/crypto/Hkdf.kt` + `utils/mac/MacInstance.kt` |
| SHA-256 | `utils/sha256/Sha256.kt` |
| X25519 ECDH | `marmot/mls/crypto/X25519.kt` (commonMain expect) |
| Ed25519 signatures | `marmot/mls/crypto/Ed25519.kt` |
| SecureRandom | `utils/SecureRandom.kt` + `utils/RandomInstance.kt` |
Nothing goes into `gradle/libs.versions.toml`. AES-ECB for header protection
is one-block and uses JDK `javax.crypto.Cipher` directly (no provider swap).
One small helper lives in the new `quic/crypto/` package:
`HkdfExpandLabel` (RFC 8446 §7.1) built on `MacInstance`. If it's clean, we
upstream it to Quartz's `Hkdf` as a general-purpose `expand(prk, info,
length)`.
## What ships first
Phases A+B+C in a single PR demonstrating TLS over QUIC handshake completion
against an in-process test server, bit-matching RFC 9001 vectors. That's the
proof-of-concept gate. If it works, the rest is execution. If it doesn't, we
abandon and revisit.