Repository navigation
Keep draining TLS plaintext after a TLS 1.3 KeyUpdate - #3738
Conversation
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 apache#3727
There was a problem hiding this comment.
🔵 Needs a closer look
The subtle SSLEngine state-machine change lacks coverage for provider-specific post-handshake states, and reliance on a scripted regression engine warrants maintainer validation across supported JDK providers.
No open issues 🎉
What changed in this PR
Updates TLS shutdown draining so accepted plaintext is preserved after a TLS 1.3 KeyUpdate, while retaining TLS 1.2 renegotiation behavior.
Changes:
- Allow established TLS 1.3 sessions to process
NEED_WRAPduring plaintext draining. - Add scripted-engine regression tests for TLS 1.3 KeyUpdate and TLS 1.2 behavior.
- Document protocol-specific draining behavior in the Scala API, Java API, and stream I/O guide.
| File | Description |
|---|---|
stream/src/main/scala/org/apache/pekko/stream/scaladsl/TLS.scala |
Documents TLS-version-specific drain behavior in the Scala API. |
stream/src/main/scala/org/apache/pekko/stream/javadsl/TLS.scala |
Documents TLS-version-specific drain behavior in the Java API. |
stream/src/main/scala/org/apache/pekko/stream/impl/io/TlsGraphStage.scala |
Permits post-handshake TLS 1.3 wrapping during accepted-plaintext draining. |
stream-tests/src/test/scala/org/apache/pekko/stream/io/TlsGraphStageIsolatedSpec.scala |
Adds scripted-engine support and regression coverage for TLS 1.3 and TLS 1.2 behavior. |
docs/src/main/paradox/stream/stream-io.md |
Updates the stream I/O guide with KeyUpdate drain semantics. |
Review effort: Lite
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 apache#3738
He-Pin
left a comment
There was a problem hiding this comment.
LGTM. Verified the drain path: PlainDataAllowedFlag is only set after a successful handshakeFinished(), so currentSession.getProtocol is always an established session when needsPostHandshakeWrap is evaluated — no NPE or false positive before the handshake. TLS 1.3 has no renegotiation, so NEED_WRAP post-establishment can only be a post-handshake message; the existing loop guards (NEED_WRAP-without-output throw, inlineHandshakeReentries cap) still cover the new path. TLS 1.2 behaviour is unchanged and guarded by the new test. Docs are in sync across paradox/scaladsl/javadsl, and CI including Binary Compatibility is green.
Motivation: The KeyUpdate drain tests use a scripted SSLEngine, so they do not show that a real provider's post-handshake states take the fixed path. Modification: Add TlsGraphStageKeyUpdateSpec, which runs a scenario in a separate JVM with a low jdk.tls.keyLimits, a JVM-wide security property. The graph stage is connected to a JDK TLS 1.3 client engine driven by the test. Ciphertext demand is withheld so the stage holds accepted plaintext while it needs to wrap a KeyUpdate, then plaintext output is cancelled. Result: The KeyUpdate fix is covered end to end with SunJSSE. Tests: - stream-tests/testOnly org.apache.pekko.stream.io.TlsGraphStageKeyUpdateSpec: passes. - With the fix disabled it fails: the peer sees the KeyUpdate but receives none of the accepted plaintext (peerReceived=[]). References: Review of apache#3738
Add apache#3727, apache#3728, apache#3731, apache#3734, apache#3738 (TLS), apache#3730 (fanout publisher performance) and apache#3733 (shard lease release logging). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add apache#3727, apache#3728, apache#3731, apache#3734, apache#3738 (TLS), apache#3730 (fanout publisher performance) and apache#3733 (shard lease release logging).
Motivation
On plaintext-output cancellation, the GraphStage TLS engine (#3727) drains plaintext it has already accepted for the established session, but stops if a new handshake is required. It treated any
NEED_WRAPhandshake status as a new handshake. A TLS 1.3 KeyUpdate that requests an update puts the JDK engine intoNEED_WRAPuntil the response is wrapped. So a KeyUpdate received before or during the drain closed outbound and discarded the accepted plaintext, although no new handshake was involved.KeyUpdates are rare in practice (the JDK triggers them after
jdk.tls.keyLimits, by default a very large data volume), but long-lived connections can hit one.Modification
TlsGraphStage.canDrainAcceptedUserInputalso allowsNEED_WRAPwhen the established session is TLS 1.3. TLS 1.3 has no renegotiation, so this is a post-handshake message such as a KeyUpdate response. The drain wraps that response first, then the accepted plaintext, then closes as before.NEED_WRAP, because it may start a renegotiation.TLSscaladoc describe the KeyUpdate case next to the existing "draining stops if a new handshake is required" text.SSLEngineinTlsGraphStageDeferredCloseSpeccan report a session protocol and, like the JDK, wrap only the handshake message (consuming no plaintext) whileNEED_WRAP.Result
A TLS 1.3 KeyUpdate no longer discards plaintext accepted before plaintext-output cancellation. TLS 1.2 behaviour is unchanged.
Tests
stream-tests/testOnly org.apache.pekko.stream.io.TlsGraphStageDeferredCloseSpec: 25 passed.keep draining accepted plaintext after a TLS 1.3 KeyUpdate received before/after output cancellation. Both fail without the fix (ByteString() was not equal to ByteString(92, 90): nothing sent, the accepted plaintext was discarded).stop draining when a TLS 1.2 engine needs to wrap during output cancellation. Guards the unchanged TLS 1.2 behaviour; passes with and without the fix.stream-tests/testOnly org.apache.pekko.stream.io.Tls*: 11 suites, 288 passedscalafmt --mode diff-ref=upstream/main: cleanTlsGraphStagelogic.The tests use a scripted engine because the JDK has no API to send a KeyUpdate, and
jdk.tls.keyLimitsis read once per JVM, so it can't be lowered reliably inside the shared test JVM.References
Refs #3727