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

20 KiB

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:

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 :nestsClientimplementation(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.