Skip to content

Keep draining TLS plaintext after a TLS 1.3 KeyUpdate - #3738

Merged
He-Pin merged 2 commits into
apache:mainfrom
pjfanning:tls13-keyupdate-drain
Oct 30, 2026
Merged

He-Pin merged 2 commits into
apache:mainfrom
pjfanning:tls13-keyupdate-drain

Conversation

@pjfanning

Copy link
Copy Markdown
Member

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_WRAP handshake status as a new handshake. A TLS 1.3 KeyUpdate that requests an update puts the JDK engine into NEED_WRAP until 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.canDrainAcceptedUserInput also allows NEED_WRAP when 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.
  • TLS 1.2 still stops the drain on NEED_WRAP, because it may start a renegotiation.
  • The stream IO guide and the scaladsl/javadsl TLS scaladoc describe the KeyUpdate case next to the existing "draining stops if a new handshake is required" text.
  • Test-only: the scripted SSLEngine in TlsGraphStageDeferredCloseSpec can report a session protocol and, like the JDK, wrap only the handshake message (consuming no plaintext) while NEED_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.
    • New: 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).
    • New: 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 passed
  • scalafmt --mode diff-ref=upstream/main: clean
  • MiMa not run: the change is a private method in the internal TlsGraphStage logic.

The tests use a scripted engine because the JDK has no API to send a KeyUpdate, and jdk.tls.keyLimits is read once per JVM, so it can't be lowered reliably inside the shared test JVM.

References

Refs #3727

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
@pjfanning pjfanning added this to the 2.0.0-M5 milestone Oct 30, 2026
@He-Pin
He-Pin requested a lite review from Copilot October 30, 2026 12:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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_WRAP during 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 He-Pin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@He-Pin
He-Pin merged commit f785e2f into apache:main Oct 30, 2026
10 checks passed
pjfanning added a commit to pjfanning/incubator-pekko that referenced this pull request Oct 30, 2026
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
pjfanning added a commit to pjfanning/incubator-pekko that referenced this pull request Oct 30, 2026
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>
pjfanning added a commit to pjfanning/incubator-pekko that referenced this pull request Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants