docs(quic-plan): drop BouncyCastle — Quartz already has every primitive

Audited Quartz's crypto surface and found every primitive the QUIC
plan called out for BouncyCastle is already present in commonMain:

- `utils/ciphers/AESGCM.kt` — AES-GCM with AAD (QUIC-TLS AEAD)
- `nip44Encryption/crypto/ChaCha20Poly1305.kt` — pure-Kotlin AEAD
- `nip44Encryption/crypto/Hkdf.kt` + `utils/mac/MacInstance.kt` — HKDF
- `utils/sha256/Sha256.kt` — SHA-256
- `marmot/mls/crypto/X25519.kt` — X25519 ECDH (TLS 1.3 key exchange)
- `marmot/mls/crypto/Ed25519.kt` — Ed25519 signatures
- `utils/SecureRandom.kt`

Plan update highlights:
- "What we delegate" section rewritten to point at Quartz primitives.
- Phase B renamed from "TLS via BouncyCastle" to "TLS 1.3 client state
  machine on Quartz primitives"; bumped from 2 to 3 weeks because we
  write the state machine ourselves, but eliminates the entire
  BC-adapter integration risk.
- "Dependencies to add" section zeroed out — nothing goes in
  gradle/libs.versions.toml. The only new primitive is a thin
  HKDF-Expand-Label helper on top of existing `MacInstance`, which
  we can upstream to Quartz's `Hkdf` as a general `expand(prk,
  info, length)`.
- Risk table rewritten: removed BC-specific risks, added
  Quartz-specific ones (verify `X25519.dh` against RFC 7748 vector
  on Phase B day-1).
- Cert chain signature verification uses JDK `Signature` for RSA +
  ECDSA plus Quartz `Ed25519` for Ed25519 leaves.

Total timeline moves from 16-18 weeks to 17-19 weeks — same band,
but with zero external dependencies and zero Maven-resolution risk.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
This commit is contained in:
Claude
2026-04-22 12:25:58 +00:00
parent 40308dc917
commit 1ff951dbb4
@@ -38,10 +38,30 @@ on top to interop with nests. This plan scopes that work honestly.
### 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.
- **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
@@ -97,18 +117,35 @@ plus an integration test against the next layer up.
- 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 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).
@@ -210,10 +247,12 @@ plus an integration test against the next layer up.
| 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) |
| 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
@@ -227,40 +266,47 @@ elsewhere.
| 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 |
| 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 |
**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.
**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
```toml
# gradle/libs.versions.toml
[versions]
bouncycastle = "1.79" # bcprov already transitively included; add bctls
**None.** Every cryptographic primitive lives in Quartz already:
[libraries]
bouncycastle-tls = { group = "org.bouncycastle", name = "bctls-jdk18on", version.ref = "bouncycastle" }
```
| 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` |
```kotlin
// nestsClient/build.gradle.kts under jvmAndroid.dependencies
implementation(libs.bouncycastle.tls)
```
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).
`bcprov-jdk18on` is already cached; verify on Phase B start that `bctls` resolves
from Maven Central before committing further.
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