diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/Buffer.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/Buffer.kt index 23bca16f6..1d94428fd 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/Buffer.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/Buffer.kt @@ -287,7 +287,7 @@ class QuicReader( } } -open class QuicCodecException( +class QuicCodecException( message: String, cause: Throwable? = null, ) : RuntimeException(message, cause) diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt index 1df43ece1..ae85304dc 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt @@ -361,39 +361,47 @@ class TlsClient( // We offered PSK but the server picked full- // handshake. RFC 8446 §4.2.11 lets the server // do this freely (rate limit, ticket aged out, - // policy mismatch). Recovering in-place - // requires: - // 1. Discarding the early secret derived - // from the resumption PSK. - // 2. Rebuilding the transcript without the - // `pre_shared_key` extension or its - // binder bytes. - // 3. Replaying the post-ClientHello derivation - // with the new transcript. - // Each step is touchy enough that a - // subtly-wrong transcript hash would land us on - // a successful handshake with wrong keys, which - // is harder to debug than a hard failure. We - // surface a typed [PskRejectedException] so - // application-layer reconnect logic can drop - // the cached resumption state and retry from - // scratch — that path is correct by - // construction (fresh ClientHello, no PSK - // history). Production callers wrapping this - // client SHOULD catch [PskRejectedException] - // and retry without [resumption]. - throw PskRejectedException( - "server rejected PSK; application must retry without resumption", - ) + // policy mismatch). + // + // Recover in-place WITHOUT a transcript rebuild. + // The on-wire ClientHello carries the + // pre_shared_key extension and binder bytes; + // both client and server hash the FULL + // ClientHello (binders included) into their + // running transcript hash regardless of accept + // / reject (RFC 8446 §4.2.11). So the + // transcript itself stays valid — only the + // [earlySecret] derivation changes: + // * accepted: early_secret = HKDF.extract(0, PSK) + // * rejected: early_secret = HKDF.extract(0, 0) + // [deriveHandshake] then folds early_secret + + // ECDHE shared secret into [handshakeSecret]; + // every downstream key follows. We just have + // to overwrite earlySecret here BEFORE + // [deriveHandshake] runs below. + // + // Any 0-RTT packets the application already + // emitted under the PSK-derived + // [clientEarlyTrafficSecret] are lost: the + // server can't decrypt them, and the EE will + // arrive without the early_data extension + // (earlyDataAccepted = false). That's the + // RFC's expected behaviour and the + // application-level retry layer is responsible + // for replaying any 0-RTT-bound payload over + // 1-RTT. The handshake itself proceeds cleanly. + keySchedule.deriveEarly() + pskAccepted = false + } else { + val r = QuicReader(pskExt.data) + val selectedIdentity = r.readUint16() + if (selectedIdentity != 0) { + throw QuicCodecException( + "server selected PSK identity $selectedIdentity but we only offered 0", + ) + } + pskAccepted = true } - val r = QuicReader(pskExt.data) - val selectedIdentity = r.readUint16() - if (selectedIdentity != 0) { - throw QuicCodecException( - "server selected PSK identity $selectedIdentity but we only offered 0", - ) - } - pskAccepted = true } else if (pskExt != null) { throw QuicCodecException("server picked PSK we never offered") } @@ -605,22 +613,6 @@ class TlsClient( } } -/** - * Thrown when the server returned a ServerHello without a `pre_shared_key` - * extension despite the client offering one in [TlsClient.resumption]. - * The handshake cannot proceed in-place because the early secret + transcript - * were already shaped around the PSK; application-layer reconnect logic - * should catch this, drop the cached [TlsResumptionState], and retry the - * handshake from scratch. See the call site in - * [TlsClient.handleHandshakeMessage] for the full rationale. - * - * Distinct subclass of [QuicCodecException] so callers can selectively - * catch the recoverable case without swallowing genuine protocol errors. - */ -class PskRejectedException( - message: String, -) : QuicCodecException(message) - /** Callback interface so the QUIC layer can react to TLS-derived secrets. */ interface TlsSecretsListener { fun onHandshakeKeysReady(