From 40308dc917fd7de61b31a7d7ed09e36a4e25519c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 08:35:09 +0000 Subject: [PATCH] docs: pure-Kotlin QUIC + WebTransport implementation plan Captures the plan to unblock Phase 3b-2 (the WebTransport handshake) by writing a Kotlin QUIC client rather than depending on a Java QUIC library. Triggered by exhausting the off-the-shelf options: - tech.kwik:* coords don't exist on Maven Central we can resolve. - Netty incubator HTTP/3 needs an Android quiche-native that isn't published. - Cronet doesn't expose WebTransport. - WebView JS bridge rejected by product. The plan delegates TLS 1.3 + crypto primitives to BouncyCastle (bcprov + bcpkix already cached, bctls to add) and has us writing the QUIC packet/frame/state-machine layer + HTTP/3 + WebTransport on top. Realistic estimate: 16-18 weeks one developer full-time, or 5-6 months at a normal cadence, plus a security review before shipping. Hard abandonment trigger documented: if the RFC 9001 Appendix A Initial-packet test vectors don't bit-match by end of Phase D (~6 weeks in), abandon and wait for an Android-compatible upstream. The plan is committed as a doc rather than executed in this session because each phase is multi-week and would block the rest of the audio-rooms feature in the meantime. https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D --- ...4-22-pure-kotlin-quic-webtransport-plan.md | 270 ++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md diff --git a/docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md b/docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md new file mode 100644 index 000000000..f64943a33 --- /dev/null +++ b/docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md @@ -0,0 +1,270 @@ +# 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 handshake state machine + AEAD primitives** → BouncyCastle + (`org.bouncycastle:bctls-jdk18on:1.79` plus `bcprov` + `bcpkix` already cached). +- **X.509 cert validation** → JDK `TrustManagerFactory` (Android system trust store). +- **AES-GCM, ChaCha20-Poly1305, HKDF** → JCA via BC provider. + +### 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 + +``` +nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/ +└── transport/ + ├── quic/ ← new: pure-Kotlin QUIC client + │ ├── crypto/ ← BC adapter (TLS over CRYPTO frames) + │ ├── packet/ ← long/short-header encode/decode + protection + │ ├── frame/ ← STREAM, ACK, CRYPTO, MAX_*, CONNECTION_CLOSE … + │ ├── stream/ ← per-stream send/recv buffers, flow control + │ ├── recovery/ ← loss detection, PTO, NewReno + │ ├── connection/ ← QuicConnection state machine + │ └── transport/ ← UDP socket + datagram pump + ├── http3/ ← new: HTTP/3 client + │ ├── frame/ ← DATA, HEADERS, SETTINGS, GOAWAY, MAX_PUSH_ID + │ ├── qpack/ ← static + dynamic tables, Huffman + │ └── Http3Connection.kt ← request/response state machine + └── webtransport/ ← new: WT layer + ├── ExtendedConnect.kt ← :protocol = webtransport + ├── WtStreamType.kt ← 0x41 (bidi) / 0x54 (uni) prefix bytes + ├── WtCapsule.kt ← WT_SESSION_CLOSE = 0x2843, etc. + └── KotlinWebTransportFactory.kt ← implements existing WebTransportFactory +``` + +Naming note: the existing `KwikWebTransportFactory.kt` stub becomes +`KotlinWebTransportFactory.kt` once real. The current ViewModel wiring +(`AudioRoomConnectionViewModel`) imports the class directly, so the rename is +one change with 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) +- UDP socket abstraction (`DatagramChannel`, suspend wrapper, single-receive loop). +- 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 already done; add CID + packet-number-space unit tests. + +### Phase B — TLS via BouncyCastle (2 weeks) +- `BcQuicTlsClient` adapts `org.bouncycastle.tls.TlsClientProtocol`. BC's TLS + client wants two streams; we wire a `PipedInputStream` / `PipedOutputStream` + pair for the input side and capture the output side via a custom + `OutputStream` that forwards bytes to the `CRYPTO`-frame queue. +- Hook `TlsCryptoProvider` callbacks to capture the per-encryption-level secrets + (Initial, Handshake, 0-RTT, 1-RTT) BC derives during the handshake. +- Implement `QuicTransportParameters` extension (codec for the TLS extension + blob carrying `initial_max_data`, `max_idle_timeout`, etc.). +- **Tests:** stand up an in-process server using BC's `TlsServerProtocol` with + the same shim; assert client + server drive each other to the Application + Data state and emit matching 1-RTT secrets. + +### 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 | +|---|---|---| +| BouncyCastle TLS 1.3 doesn't expose the secret-derivation hooks QUIC needs | Investigate BC's `TlsCryptoProvider` early in Phase B before committing the rest | If by end of Phase B we can't get per-encryption-level secrets out of BC, switch to `bctls`'s lower-level `TlsCrypto` API and implement the TLS 1.3 record layer ourselves on top (~+4 weeks) | +| 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) | +| 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 via BC | 2 | 3 | +| C. Initial + Handshake packets | 2 | 5 | +| D. 1-RTT + STREAM | 1 | 6 | +| E. ACK + flow control | 1 | 7 | +| F. Loss + congestion | 1 | 8 | +| G. Datagrams | ½ | 8.5 | +| H. Connection lifecycle | 1 | 9.5 | +| I. HTTP/3 | 2 | 11.5 | +| J. QPACK | 2 | 13.5 | +| K. Extended CONNECT + WT | 1 | 14.5 | +| L. Interop + hardening | 2 | 16.5 | + +**16-18 weeks of full-time work for one developer**, or 5-6 months at a normal +review-and-iteration cadence. Add a security review of the QUIC + TLS code paths +before shipping to users — minimum 2 weeks calendar time, possibly external. + +## Dependencies to add + +```toml +# gradle/libs.versions.toml +[versions] +bouncycastle = "1.79" # bcprov already transitively included; add bctls + +[libraries] +bouncycastle-tls = { group = "org.bouncycastle", name = "bctls-jdk18on", version.ref = "bouncycastle" } +``` + +```kotlin +// nestsClient/build.gradle.kts under jvmAndroid.dependencies +implementation(libs.bouncycastle.tls) +``` + +`bcprov-jdk18on` is already cached; verify on Phase B start that `bctls` resolves +from Maven Central before committing further. + +## 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.