Skip to content

Commit f785e2f

Browse files
authored
Keep draining TLS plaintext after a TLS 1.3 KeyUpdate (#3738)
* Keep draining TLS plaintext after a TLS 1.3 KeyUpdate Motivation: On plaintext-output cancellation the GraphStage TLS engine drains plaintext it has already accepted, but stops if a new handshake is required. It treated any NEED_WRAP as a new handshake, so a TLS 1.3 KeyUpdate that requests a response, received before or during the drain, closed outbound and discarded the accepted plaintext. Modification: - Allow the drain to continue when an established TLS 1.3 session needs to wrap. TLS 1.3 has no renegotiation, so this is a post-handshake message such as a KeyUpdate response; the response is wrapped before the plaintext. - TLS 1.2 keeps stopping the drain, because NEED_WRAP may start a renegotiation. - Document the KeyUpdate case in the stream IO guide and both TLS DSLs. Result: A TLS 1.3 KeyUpdate no longer discards plaintext accepted before plaintext-output cancellation. Tests: - stream-tests/testOnly TlsGraphStageDeferredCloseSpec: 25 passed; the two new TLS 1.3 KeyUpdate tests fail without the fix (ByteString() was not equal to ByteString(92, 90)), the TLS 1.2 guard passes both ways - stream-tests/testOnly org.apache.pekko.stream.io.Tls*: 288 passed - scalafmt --mode diff-ref=upstream/main: clean References: Refs #3727 * Match SunJSSE's FINISHED status for scripted KeyUpdate wraps Motivation: Review noted the KeyUpdate tests rely on a scripted engine. A real SunJSSE TLS 1.3 KeyUpdate (forced with a low jdk.tls.keyLimits) reports NEED_WRAP after unwrap, and the response wrap returns FINISHED, not NOT_HANDSHAKING as scripted. FINISHED re-runs session verification. Modification: Return FINISHED from the scripted KeyUpdate response wrap, leaving the engine in NOT_HANDSHAKING afterwards, like SunJSSE. Result: The drain tests now cover the FINISHED path a real KeyUpdate takes. Tests: - stream-tests/testOnly org.apache.pekko.stream.io.TlsGraphStage*: 173 passed. - With the fix disabled, both TLS 1.3 KeyUpdate tests fail. - Standalone JDK 17 SunJSSE engine pair with low jdk.tls.keyLimits confirmed the NEED_WRAP then FINISHED sequence on TLSv1.3. References: Review of #3738
1 parent 5b96b83 commit f785e2f

5 files changed

Lines changed: 76 additions & 8 deletions

File tree

‎docs/src/main/paradox/stream/stream-io.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,9 @@ With `pekko.stream.materializer.tls.engine = graph-stage`, the operator stops fu
188188
and attempts to send data already accepted for the established session, subject to transport
189189
availability. This includes up to 64 queued messages plus plaintext already buffered for wrapping.
190190
Message sizes are unbounded, so termination can be delayed until that data is sent. Data queued after
191-
a renegotiation request is discarded, and draining stops if a new handshake is required. Cancellation
191+
a renegotiation request is discarded, and draining stops if a new handshake is required. A TLS 1.3
192+
post-handshake message such as a KeyUpdate is not a new handshake, so the response is sent and draining
193+
continues; in TLS 1.2 any further handshake stops the drain. Cancellation
192194
during a handshake or a transport failure can still prevent delivery. The default `legacy-actor`
193195
engine discards accepted plaintext on cancellation. Use the `IgnoreCancel` closing policy
194196
(`TLSClosing.ignoreCancel()` in Java) if the handler needs to continue producing a response after it

‎stream-tests/src/test/scala/org/apache/pekko/stream/io/TlsGraphStageIsolatedSpec.scala‎

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,7 @@ abstract class TlsGraphStageBufferTestBase(config: Config) extends StreamSpec(co
299299
protected val TlsRecord = ByteString(Array[Byte](23, 3, 3, 0, 0))
300300
protected val ApplicationCiphertext = ByteString(0x5A.toByte)
301301
protected val CloseCiphertext = ByteString(0x5B.toByte)
302+
protected val HandshakeCiphertext = ByteString(0x5C.toByte)
302303

303304
protected final class ScriptedSSLEngine(
304305
delegate: SSLEngine,
@@ -307,7 +308,9 @@ abstract class TlsGraphStageBufferTestBase(config: Config) extends StreamSpec(co
307308
requestWrapOnSubsequentUnwrap: Boolean,
308309
@volatile var requiredOutputSpace: Int,
309310
negotiatedPacketSize: Int,
310-
negotiatedApplicationSize: Int) extends SSLEngine {
311+
negotiatedApplicationSize: Int,
312+
sessionProtocol: String = null,
313+
wrapHandshakeOnNeedWrap: Boolean = false) extends SSLEngine {
311314
private var handshakeStatus: SSLEngineResult.HandshakeStatus = NEED_UNWRAP
312315
@volatile var unwrapCount = 0
313316
private var inboundDone = false
@@ -334,6 +337,7 @@ abstract class TlsGraphStageBufferTestBase(config: Config) extends StreamSpec(co
334337
(_: AnyRef, method: java.lang.reflect.Method, args: Array[AnyRef]) =>
335338
if (method.getName == "getPacketBufferSize") Int.box(reportedPacketSize)
336339
else if (method.getName == "getApplicationBufferSize") Int.box(reportedApplicationSize)
340+
else if (method.getName == "getProtocol" && (sessionProtocol ne null)) sessionProtocol
337341
else method.invoke(delegate.getSession, Option(args).getOrElse(Array.empty[AnyRef]): _*))
338342
.asInstanceOf[SSLSession]
339343

@@ -356,6 +360,13 @@ abstract class TlsGraphStageBufferTestBase(config: Config) extends StreamSpec(co
356360
overflowWraps += 1
357361
return new SSLEngineResult(BUFFER_OVERFLOW, handshakeStatus, 0, 0)
358362
}
363+
if (wrapHandshakeOnNeedWrap && handshakeStatus == NEED_WRAP && !(wrapOnCloseInbound && inboundDone)) {
364+
// Like the JDK, a wrap while NEED_WRAP produces only the handshake message and consumes no plaintext.
365+
// SunJSSE reports FINISHED for a TLS 1.3 KeyUpdate response, then NOT_HANDSHAKING from the engine.
366+
dst.put(HandshakeCiphertext.head)
367+
handshakeStatus = NOT_HANDSHAKING
368+
return new SSLEngineResult(OK, FINISHED, 0, 1)
369+
}
359370
var consumed = 0
360371
var index = offset
361372
while (index < offset + length) {
@@ -440,7 +451,7 @@ abstract class TlsGraphStageBufferTestBase(config: Config) extends StreamSpec(co
440451
override def getDelegatedTask: Runnable = null
441452
override def getSession: SSLSession = {
442453
sessionQueries += 1
443-
if (negotiatedPacketSize > 0) reportedSession else delegate.getSession
454+
if (negotiatedPacketSize > 0 || (sessionProtocol ne null)) reportedSession else delegate.getSession
444455
}
445456
override def getSupportedCipherSuites: Array[String] = delegate.getSupportedCipherSuites
446457
override def getEnabledCipherSuites: Array[String] = delegate.getEnabledCipherSuites
@@ -474,11 +485,14 @@ abstract class TlsGraphStageBufferTestBase(config: Config) extends StreamSpec(co
474485
extraAttributes: Attributes = Attributes.none,
475486
requiredOutputSpace: Int = 0,
476487
negotiatedPacketSize: Int = 0,
477-
negotiatedApplicationSize: Int = 0): DeferredCloseHarness = {
488+
negotiatedApplicationSize: Int = 0,
489+
sessionProtocol: String = null,
490+
wrapHandshakeOnNeedWrap: Boolean = false): DeferredCloseHarness = {
478491
val engine =
479492
new ScriptedSSLEngine(TlsSpec.initSslContext("TLSv1.2").createSSLEngine(), wrapOnCloseInbound,
480493
requestHandshakeAfterApplication, requestWrapOnSubsequentUnwrap,
481-
requiredOutputSpace, negotiatedPacketSize, negotiatedApplicationSize)
494+
requiredOutputSpace, negotiatedPacketSize, negotiatedApplicationSize, sessionProtocol,
495+
wrapHandshakeOnNeedWrap)
482496
val sessionVerified = Promise[Unit]()
483497
val probes =
484498
RunnableGraph
@@ -624,6 +638,48 @@ class TlsGraphStageDeferredCloseSpec extends TlsGraphStageBufferTestBase(TlsGrap
624638
harness.engine.applicationBytes shouldBe 1
625639
}
626640

641+
// The scripted engine answers the first post-handshake unwrap with NEED_WRAP, like a received
642+
// TLS 1.3 KeyUpdate that requests an update, and wraps the response before any plaintext.
643+
for (keyUpdateBeforeCancel <- Seq(true, false)) {
644+
val order = if (keyUpdateBeforeCancel) "before" else "after"
645+
s"keep draining accepted plaintext after a TLS 1.3 KeyUpdate received $order output cancellation" in {
646+
val harness = newHarness(sessionProtocol = "TLSv1.3", wrapHandshakeOnNeedWrap = true)
647+
finishInitialHandshake(harness)
648+
harness.plainIn.sendNext(SendBytes(ByteString("a")))
649+
harness.plainIn.expectRequest()
650+
if (keyUpdateBeforeCancel) {
651+
harness.cipherIn.sendNext(TlsRecord)
652+
awaitAssert(harness.engine.unwrapCount shouldBe 2)
653+
}
654+
harness.plainOut.cancel()
655+
harness.plainIn.expectCancellation()
656+
if (!keyUpdateBeforeCancel) {
657+
harness.cipherIn.sendNext(TlsRecord)
658+
awaitAssert(harness.engine.unwrapCount shouldBe 2)
659+
}
660+
661+
// A KeyUpdate response is not a new handshake, so it must not discard the accepted plaintext.
662+
val sent = harness.cipherOut.toStrict(remainingOrDefault).foldLeft(ByteString.empty)(_ ++ _)
663+
sent shouldBe HandshakeCiphertext ++ ApplicationCiphertext
664+
harness.engine.applicationBytes shouldBe 1
665+
}
666+
}
667+
668+
"stop draining when a TLS 1.2 engine needs to wrap during output cancellation" in {
669+
val harness = newHarness(sessionProtocol = "TLSv1.2", wrapHandshakeOnNeedWrap = true)
670+
finishInitialHandshake(harness)
671+
harness.plainIn.sendNext(SendBytes(ByteString("a")))
672+
harness.plainIn.expectRequest()
673+
harness.cipherIn.sendNext(TlsRecord)
674+
awaitAssert(harness.engine.unwrapCount shouldBe 2)
675+
harness.plainOut.cancel()
676+
harness.plainIn.expectCancellation()
677+
678+
// TLS 1.2 NEED_WRAP may start a renegotiation, so draining must stop instead of waiting for it.
679+
harness.cipherOut.toStrict(remainingOrDefault) shouldBe empty
680+
harness.engine.applicationBytes shouldBe 0
681+
}
682+
627683
"stop draining when the transport output is cancelled" in {
628684
val harness = newHarness()
629685
finishInitialHandshake(harness)

‎stream/src/main/scala/org/apache/pekko/stream/impl/io/TlsGraphStage.scala‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,13 @@ import pekko.util.ByteString
412412
userOutput.isCancelled && !closing.ignoreCancel && canDrainAcceptedUserInput &&
413413
(userInBufferHasRemaining || !userInput.isEmpty)
414414

415-
private def canDrainAcceptedUserInput: Boolean = (stateBits & PlainDataAllowedFlag) != 0 && mayCloseOutbound
415+
private def canDrainAcceptedUserInput: Boolean = (stateBits & PlainDataAllowedFlag) != 0 &&
416+
(mayCloseOutbound || needsPostHandshakeWrap)
417+
418+
// TLS 1.3 has no renegotiation, so NEED_WRAP in an established session is a post-handshake
419+
// message such as a KeyUpdate response. Wrapping it does not start a new handshake.
420+
private def needsPostHandshakeWrap: Boolean =
421+
lastHandshakeStatus == NEED_WRAP && currentSession.getProtocol == "TLSv1.3"
416422

417423
private def mayCloseOutbound: Boolean =
418424
lastHandshakeStatus match {

‎stream/src/main/scala/org/apache/pekko/stream/javadsl/TLS.scala‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ import pekko.util.ByteString
5454
* drain includes up to 64 queued messages plus any plaintext already buffered for
5555
* wrapping; message sizes are unbounded, so cancellation can delay termination
5656
* until that data is sent. Data queued after a renegotiation request is discarded,
57-
* and draining stops if a new handshake is required. The drain continues after a
57+
* and draining stops if a new handshake is required. A TLS 1.3 post-handshake
58+
* message such as a KeyUpdate is not a new handshake and does not stop the drain;
59+
* in TLS 1.2 any further handshake stops it. The drain continues after a
5860
* TLS 1.3 peer sends `close_notify`, but a TLS 1.2 `close_notify` ends it. The engine
5961
* is selected with `pekko.stream.materializer.tls.engine`, which defaults to
6062
* `legacy-actor`.

‎stream/src/main/scala/org/apache/pekko/stream/scaladsl/TLS.scala‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ import com.typesafe.config.{ ConfigException, ConfigFactory }
5656
* drain includes up to 64 queued messages plus any plaintext already buffered for
5757
* wrapping; message sizes are unbounded, so cancellation can delay termination
5858
* until that data is sent. Data queued after a renegotiation request is discarded,
59-
* and draining stops if a new handshake is required. The drain continues after a
59+
* and draining stops if a new handshake is required. A TLS 1.3 post-handshake
60+
* message such as a KeyUpdate is not a new handshake and does not stop the drain;
61+
* in TLS 1.2 any further handshake stops it. The drain continues after a
6062
* TLS 1.3 peer sends `close_notify`, but a TLS 1.2 `close_notify` ends it. The engine
6163
* is selected with `pekko.stream.materializer.tls.engine`, which defaults to
6264
* `legacy-actor`.

0 commit comments

Comments
 (0)