diff --git a/quic/build.gradle.kts b/quic/build.gradle.kts index 5814d2ecc0..83503de9b9 100644 --- a/quic/build.gradle.kts +++ b/quic/build.gradle.kts @@ -99,3 +99,28 @@ kotlin { } } } + +/** + * Run the live-interop runner against a real QUIC server. Configure target + * via -PinteropHost=… -PinteropPort=… -PinteropTimeoutSec=… + * + * Usage: + * ./gradlew :quic:interop -PinteropHost=127.0.0.1 -PinteropPort=4433 + */ +tasks.register("interop") { + group = "verification" + description = "Drive QuicConnection against a real QUIC server (default 127.0.0.1:4433)." + dependsOn("jvmTestClasses", "jvmJar") + classpath = + files( + tasks.named("jvmJar"), + configurations.named("jvmTestRuntimeClasspath"), + layout.buildDirectory.dir("classes/kotlin/jvm/test"), + ) + mainClass.set("com.vitorpamplona.quic.interop.InteropRunnerKt") + val host = (project.findProperty("interopHost") as? String) ?: "127.0.0.1" + val port = (project.findProperty("interopPort") as? String) ?: "4433" + val timeoutSec = (project.findProperty("interopTimeoutSec") as? String) ?: "10" + args(host, port) + systemProperty("interopTimeoutSec", timeoutSec) +} diff --git a/quic/scripts/README.md b/quic/scripts/README.md new file mode 100644 index 0000000000..dd57f05958 --- /dev/null +++ b/quic/scripts/README.md @@ -0,0 +1,70 @@ +# `:quic` interop harness + +Scripts and test entry points for driving the pure-Kotlin QUIC client against +real reference servers. + +## Quickstart — picoquic + +```bash +# Terminal 1: run the picoquic reference server in Docker. +quic/scripts/run-picoquic.sh -d + +# Terminal 2: drive our client at it. +./gradlew :quic:jvmTestClasses +java -cp "$(./gradlew -q :quic:printTestRuntimeClasspath)" \ + com.vitorpamplona.quic.interop.InteropRunnerKt 127.0.0.1 4433 +``` + +Expected output: + +``` +== :quic interop runner == +target: 127.0.0.1:4433 +timeout: 10s + +✓ HANDSHAKE COMPLETE + status: CONNECTED + negotiated ALPN: h3 + peer transport params: max_data=…, max_streams_bidi=…, … +``` + +## What this proves + +- TCP-equivalent UDP connection setup +- QUIC v1 Initial / Handshake / 1-RTT packet flow with RFC-correct PADDING +- TLS 1.3 over QUIC with the SHA-256 cipher suites +- ALPN `h3` negotiation +- QUIC transport parameters round-trip +- Header protection (AES-ECB) +- AEAD payload protection (AES-128-GCM) + +It does NOT yet prove WebTransport / MoQ — picoquic doesn't speak WT. + +## Live nests interop + +For a full WebTransport + MoQ test against the actual Nostr nests server, the +target is `nostrnests.com:443`: + +```bash +java -cp "..." com.vitorpamplona.quic.interop.InteropRunnerKt nostrnests.com 443 +``` + +This goes against the real CA-signed cert, so revert to the default +`JdkCertificateValidator` (the InteropRunner currently uses +`PermissiveCertificateValidator` for self-signed dev servers — change the +constant before pointing at production). + +## Other reference servers worth trying + +| Server | Image | Notes | +|---|---|---| +| picoquic | `privateoctopus/picoquic` | Most permissive; clear qlog traces | +| quic-go | `martenseemann/quic-go-interop` | Stable, widest scenario coverage | +| aioquic | `aiortc/aioquic` | Easy to debug, Python reference | +| quiche | `cloudflare/quiche` | Production-grade, strict | +| nests-rs | (local cargo build) | The actual MoQ relay; needs WebTransport | + +The IETF's [`quic-interop-runner`](https://github.com/quic-interop/quic-interop-runner) +exposes all of these via a single Docker matrix. Wrapping our client in its +container contract (`TESTCASE` env, `REQUESTS=` URL list, `/certs` mount) would +let us join the public matrix at https://interop.seemann.io. diff --git a/quic/scripts/run-picoquic.sh b/quic/scripts/run-picoquic.sh new file mode 100755 index 0000000000..87dd12f783 --- /dev/null +++ b/quic/scripts/run-picoquic.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Spin up Christian Huitema's picoquic reference server on UDP 4433. +# Picoquic is the IETF QUIC WG's reference impl and the most permissive +# server for a brand-new client to interop against — it logs detailed +# qlog traces and accepts a wide range of transport parameters. +# +# Usage: +# quic/scripts/run-picoquic.sh # foreground, ^C to stop +# quic/scripts/run-picoquic.sh -d # detached +# +# Then in another terminal, drive our client: +# ./gradlew :quic:jvmTest --tests '*InteropRunner*' \ +# -DinteropHost=127.0.0.1 -DinteropPort=4433 +# +# Or compile + run the runner main directly: +# ./gradlew :quic:jvmTestClasses +# java -cp 'quic/build/classes/kotlin/jvm/{main,test}:…' \ +# com.vitorpamplona.quic.interop.InteropRunnerKt 127.0.0.1 4433 + +set -euo pipefail + +DETACH=${1:-} + +IMAGE="privateoctopus/picoquic:latest" +CONTAINER="amethyst-picoquic-interop" + +echo "Pulling $IMAGE..." +docker pull "$IMAGE" >/dev/null + +# Stop any prior instance so re-runs are idempotent. +docker rm -f "$CONTAINER" 2>/dev/null || true + +if [ "$DETACH" = "-d" ]; then + docker run -d --name "$CONTAINER" -p 4433:4433/udp "$IMAGE" \ + picoquicdemo -p 4433 + echo "picoquic started (container=$CONTAINER) on UDP 4433" + echo "Stop with: docker rm -f $CONTAINER" +else + exec docker run --rm --name "$CONTAINER" -p 4433:4433/udp "$IMAGE" \ + picoquicdemo -p 4433 +fi diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/PermissiveCertificateValidator.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/PermissiveCertificateValidator.kt new file mode 100644 index 0000000000..12671588c9 --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/PermissiveCertificateValidator.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quic.tls + +/** + * Test-only certificate validator that accepts every chain and every + * signature algorithm. Useful for connecting to local development servers + * with self-signed certificates (picoquic, nests-rs, quic-go's `interop` + * server, etc.) where the system trust store would reject the cert. + * + * **NEVER** wire this into production code. The whole point of the + * required-validator design is that the type system catches misuse — pass + * this only from explicit test entry points. + */ +class PermissiveCertificateValidator : CertificateValidator { + override fun validateChain( + chain: List, + expectedHost: String, + ) { + // Accept anything. No-op. + } + + override fun verifySignature( + signatureAlgorithm: Int, + signature: ByteArray, + transcriptHash: ByteArray, + ) { + // Accept anything. No-op. + } +} diff --git a/quic/src/jvmTest/kotlin/com/vitorpamplona/quic/interop/InteropRunner.kt b/quic/src/jvmTest/kotlin/com/vitorpamplona/quic/interop/InteropRunner.kt new file mode 100644 index 0000000000..d33c7fc329 --- /dev/null +++ b/quic/src/jvmTest/kotlin/com/vitorpamplona/quic/interop/InteropRunner.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quic.interop + +import com.vitorpamplona.quic.connection.QuicConnection +import com.vitorpamplona.quic.connection.QuicConnectionConfig +import com.vitorpamplona.quic.connection.QuicConnectionDriver +import com.vitorpamplona.quic.tls.PermissiveCertificateValidator +import com.vitorpamplona.quic.transport.UdpSocket +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Standalone interop runner. Drives [QuicConnection] against a real QUIC + * server (picoquic, quic-go, quiche, nests-rs, etc.) and reports whether + * the handshake completes. + * + * Run via: + * + * ./gradlew :quic:jvmTest --tests '*InteropRunner*' \ + * -DinteropHost=localhost -DinteropPort=4433 + * + * Or invoke main() directly from an IDE. + * + * The runner uses [PermissiveCertificateValidator] — pointing this at a + * production server is a security misconfiguration; it's intentionally + * test-only. + */ +fun main(args: Array) { + val host = System.getProperty("interopHost") ?: args.getOrNull(0) ?: "127.0.0.1" + val port = (System.getProperty("interopPort") ?: args.getOrNull(1) ?: "4433").toInt() + val timeoutSec = (System.getProperty("interopTimeoutSec") ?: "10").toLong() + + println("== :quic interop runner ==") + println("target: $host:$port") + println("timeout: ${timeoutSec}s") + println() + + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val outcome = + runBlocking { + val socket = + try { + UdpSocket.connect(host, port) + } catch (t: Throwable) { + return@runBlocking InteropOutcome.UdpFailed(t.message ?: t::class.simpleName ?: "?") + } + + val conn = + QuicConnection( + serverName = host, + config = QuicConnectionConfig(), + tlsCertificateValidator = PermissiveCertificateValidator(), + ) + val driver = QuicConnectionDriver(conn, socket, scope) + driver.start() + + val handshakeResult = + withTimeoutOrNull(timeoutSec * 1_000L) { + runCatching { conn.awaitHandshake() } + } + val outcome = + when { + handshakeResult == null -> { + InteropOutcome.Timeout + } + + handshakeResult.isSuccess -> { + InteropOutcome.Connected(conn) + } + + else -> { + InteropOutcome.HandshakeFailed( + handshakeResult.exceptionOrNull()?.message ?: "?", + ) + } + } + try { + driver.close() + } catch (_: Throwable) { + } + outcome + } + + when (outcome) { + is InteropOutcome.Connected -> { + println("✓ HANDSHAKE COMPLETE") + println(" status: ${outcome.conn.status}") + println(" negotiated ALPN: ${outcome.conn.tls.negotiatedAlpn?.decodeToString()}") + println(" peer transport params: ${outcome.conn.peerTransportParameters?.let { tpSummary(it) } ?: "(none)"}") + } + + is InteropOutcome.HandshakeFailed -> { + println("✗ HANDSHAKE FAILED") + println(" reason: ${outcome.reason}") + kotlin.system.exitProcess(1) + } + + InteropOutcome.Timeout -> { + println("✗ TIMED OUT after ${timeoutSec}s") + kotlin.system.exitProcess(1) + } + + is InteropOutcome.UdpFailed -> { + println("✗ UDP CONNECT FAILED") + println(" reason: ${outcome.reason}") + kotlin.system.exitProcess(1) + } + } +} + +private fun tpSummary(tp: com.vitorpamplona.quic.connection.TransportParameters): String = + listOfNotNull( + tp.initialMaxData?.let { "max_data=$it" }, + tp.initialMaxStreamsBidi?.let { "max_streams_bidi=$it" }, + tp.initialMaxStreamsUni?.let { "max_streams_uni=$it" }, + tp.maxIdleTimeoutMillis?.let { "idle_timeout=${it}ms" }, + tp.maxDatagramFrameSize?.let { "max_datagram=$it" }, + ).joinToString(", ") + +private sealed class InteropOutcome { + data class Connected( + val conn: QuicConnection, + ) : InteropOutcome() + + data class HandshakeFailed( + val reason: String, + ) : InteropOutcome() + + data class UdpFailed( + val reason: String, + ) : InteropOutcome() + + object Timeout : InteropOutcome() +}