diff --git a/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt index e99ff55d7..485852351 100644 --- a/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt +++ b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt @@ -122,14 +122,31 @@ class QuicWebTransportFactory( val driver = QuicConnectionDriver(conn, socket, parentScope) driver.start() - try { - conn.awaitHandshake() - } catch (t: Throwable) { + val handshakeCompleted = + try { + // Bound the handshake — a server-side TLS failure (e.g. a relay + // that rejects an IP-literal SNI and can't select a cert) leaves + // the handshake stalled with no alert, so without this ceiling + // connect() hangs indefinitely instead of failing fast. + kotlinx.coroutines.withTimeoutOrNull(connectTimeoutMillis) { + conn.awaitHandshake() + true + } + } catch (t: Throwable) { + driver.close() + throw WebTransportException( + kind = WebTransportException.Kind.HandshakeFailed, + message = "QUIC handshake failed: ${t.message}", + cause = t, + ) + } + if (handshakeCompleted == null) { driver.close() throw WebTransportException( kind = WebTransportException.Kind.HandshakeFailed, - message = "QUIC handshake failed: ${t.message}", - cause = t, + message = + "QUIC handshake stalled — no completion within ${connectTimeoutMillis}ms " + + "(a server-side TLS failure such as a rejected IP-literal SNI hangs here with no alert)", ) } if (conn.status != QuicConnection.Status.CONNECTED) { diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt index 6cd356ce1..e647cc926 100644 --- a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt @@ -205,7 +205,14 @@ class NostrNestsHarness private constructor( // The path under this base is the namespace literal (per // moq-rs `claims.root` matching); the `:nestsClient` connect // helpers append `/?jwt=` themselves. - moqEndpoint = "https://127.0.0.1:$MOQ_HOST_PORT/", + // + // Host MUST be `localhost`, not `127.0.0.1`: the QUIC client + // sends the host as TLS SNI, RFC 6066 §3 forbids IP literals + // there, and a strict relay (rustls) rejects an IP-literal SNI + // and then has no SNI to select its dev cert on — the handshake + // stalls. `localhost` is a valid hostname and matches the dev + // cert moq-relay generates. Resolves to 127.0.0.1 regardless. + moqEndpoint = "https://localhost:$MOQ_HOST_PORT/", ) } @@ -242,7 +249,9 @@ class NostrNestsHarness private constructor( return NostrNestsHarness( workDir = null, authBaseUrl = "http://127.0.0.1:$AUTH_HOST_PORT", - moqEndpoint = "https://127.0.0.1:$MOQ_HOST_PORT/", + // `localhost`, not `127.0.0.1` — see the doStart() return for + // why an IP-literal host breaks the TLS SNI / cert selection. + moqEndpoint = "https://localhost:$MOQ_HOST_PORT/", ) } diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClientHello.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClientHello.kt index 35d7e7e30..b7153320a 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClientHello.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClientHello.kt @@ -107,16 +107,21 @@ fun buildQuicClientHello( ), ): TlsClientHello { val exts = - listOf( - TlsExtension(TlsConstants.EXT_SERVER_NAME, encodeServerNameExtension(serverName)), - TlsExtension(TlsConstants.EXT_SUPPORTED_VERSIONS, encodeSupportedVersionsExtensionClient()), - TlsExtension(TlsConstants.EXT_SUPPORTED_GROUPS, encodeSupportedGroupsX25519()), - TlsExtension(TlsConstants.EXT_SIGNATURE_ALGORITHMS, encodeSignatureAlgorithms()), - TlsExtension(TlsConstants.EXT_KEY_SHARE, encodeKeyShareClientX25519(x25519PublicKey)), - TlsExtension(TlsConstants.EXT_PSK_KEY_EXCHANGE_MODES, encodePskKeyExchangeModesDhe()), - TlsExtension(TlsConstants.EXT_ALPN, encodeAlpn(alpns)), - TlsExtension(TlsConstants.EXT_QUIC_TRANSPORT_PARAMETERS, quicTransportParams), - ) + buildList { + // RFC 6066 §3: omit the SNI extension entirely for IP-literal + // targets — a literal address is not a valid host_name and a + // strict server rejects the extension, then fails cert selection. + if (!isIpLiteralHostname(serverName)) { + add(TlsExtension(TlsConstants.EXT_SERVER_NAME, encodeServerNameExtension(serverName))) + } + add(TlsExtension(TlsConstants.EXT_SUPPORTED_VERSIONS, encodeSupportedVersionsExtensionClient())) + add(TlsExtension(TlsConstants.EXT_SUPPORTED_GROUPS, encodeSupportedGroupsX25519())) + add(TlsExtension(TlsConstants.EXT_SIGNATURE_ALGORITHMS, encodeSignatureAlgorithms())) + add(TlsExtension(TlsConstants.EXT_KEY_SHARE, encodeKeyShareClientX25519(x25519PublicKey))) + add(TlsExtension(TlsConstants.EXT_PSK_KEY_EXCHANGE_MODES, encodePskKeyExchangeModesDhe())) + add(TlsExtension(TlsConstants.EXT_ALPN, encodeAlpn(alpns))) + add(TlsExtension(TlsConstants.EXT_QUIC_TRANSPORT_PARAMETERS, quicTransportParams)) + } return TlsClientHello(random = random, cipherSuites = cipherSuites, extensions = exts) } @@ -162,7 +167,10 @@ fun buildResumptionClientHelloBytes( ): ByteArray { val exts = buildList { - add(TlsExtension(TlsConstants.EXT_SERVER_NAME, encodeServerNameExtension(serverName))) + // RFC 6066 §3: no SNI for IP-literal targets — see buildQuicClientHello. + if (!isIpLiteralHostname(serverName)) { + add(TlsExtension(TlsConstants.EXT_SERVER_NAME, encodeServerNameExtension(serverName))) + } add(TlsExtension(TlsConstants.EXT_SUPPORTED_VERSIONS, encodeSupportedVersionsExtensionClient())) add(TlsExtension(TlsConstants.EXT_SUPPORTED_GROUPS, encodeSupportedGroupsX25519())) add(TlsExtension(TlsConstants.EXT_SIGNATURE_ALGORITHMS, encodeSignatureAlgorithms())) diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsExtension.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsExtension.kt index 71c76fb2c..89496d2e1 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsExtension.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsExtension.kt @@ -85,6 +85,32 @@ fun encodeServerNameExtension(hostName: String): ByteArray { return w.toByteArray() } +/** + * RFC 6066 §3: the SNI `host_name` field MUST carry a DNS hostname — literal + * IPv4 and IPv6 addresses are explicitly NOT permitted. Strict servers reject + * an IP-literal SNI outright (e.g. `rustls`: "Illegal SNI extension: ignoring + * IP address presented as hostname"), which leaves the server with no SNI to + * key certificate selection on and stalls the handshake. Callers that build a + * ClientHello for an IP-literal target must therefore omit the `server_name` + * extension entirely; this predicate is the gate for that. + * + * Returns true for dotted-quad IPv4 (`127.0.0.1`) and for anything containing + * a `:` or wrapped in `[...]` (every IPv6 literal; no DNS hostname can contain + * a colon). Anything else — including a bare `localhost` — is treated as a + * hostname and gets a normal SNI. + */ +fun isIpLiteralHostname(host: String): Boolean { + if (host.isEmpty()) return false + // IPv6 literals always contain ':' (optionally bracketed); DNS names never do. + if (host.startsWith('[') || host.contains(':')) return true + // IPv4 dotted-quad: exactly four decimal octets in 0..255. + val parts = host.split('.') + if (parts.size != 4) return false + return parts.all { p -> + p.isNotEmpty() && p.length <= 3 && p.all { it in '0'..'9' } && p.toInt() in 0..255 + } +} + /** Build the `supported_versions` extension carrying just TLS 1.3. */ fun encodeSupportedVersionsExtensionClient(): ByteArray { val w = QuicWriter()