Skip to content

TLS: session resumption not observable through the memory-BIO transport #314

Description

@mvandeberg

TLS: session resumption not observable through the memory-BIO transport

Labels: tls, enhancement, blocked
Component: src/openssl, src/wolfssl, TLS stream transport
Blocked by: #312

Summary

corosio has no session-resumption API. An explicit, opaque tls_session
handle plus session() / set_session() / session_reused() accessors were
designed and prototyped, but resumption could not be observed through
corosio's in-memory BIO transport: the server's NewSessionTicket (and, for
TLS 1.2, the session it establishes) never became resumable on the client, so
the API was deferred and removed rather than shipped non-functional. This
issue records the behavior, how to confirm it, and how to move forward.

Session resumption lets a client skip the full handshake on reconnect by
reusing a previously negotiated session (TLS 1.2 session IDs / TLS 1.3
tickets). It is a performance optimization, not a security feature — a
backend that cannot resume simply does a full handshake, so the policy is
best-effort, never fail-closed.

Current state

  • No public API. No tls_session, session(), set_session(), or
    session_reused() in include/boost/corosio/.
  • A full design exists and is still valid for re-introduction:
    ~/.claude/plans/create-a-plan-to-keen-lamport.md (opaque tls_session
    handle over a shared_ptr<session_holder>; three non-pure virtuals on
    tls_stream defaulting to empty/false; client-side first, server accept
    layered later; in-memory only; TDD pinned to TLS 1.2).

Why it is blocked

Same root cause as OCSP stapling (see tasks/tls/ocsp-stapling.md): corosio
drives OpenSSL through a memory-BIO pair rather than a socket, and records
the server emits at/after the tail of the handshake are not pumped to the
client before the handshake coroutine returns.

Observed behavior:

  • TLS 1.3: after a full handshake the client's SSL_get1_session(ssl)
    yields a session with SSL_SESSION_is_resumable() == 0. The TLS 1.3
    NewSessionTicket is a post-handshake message — it arrives only once the
    client performs a read after the handshake, but do_handshake returns
    immediately on SSL_connect == 1 without that read:

    // src/openssl/src/openssl_stream.cpp  (do_handshake, ~line 903)
    if (ret == 1) { used_ = true; capture_alpn();
                    ec = co_await flush_output(); co_return {ec}; }  // no read
    
  • TLS 1.2: even though NewSessionTicket is part of the server's final
    flight, a second connection reusing the captured session reported
    SSL_session_reused(ssl) == 0 when both peers ran over the memory-BIO pair.

Over a real socket pair the same OpenSSL configuration resumes correctly
(round 1 SSL_SESSION_is_resumable() == 1; round 2 SSL_session_reused() == 1), which localizes the fault to the transport, not the SSL setup.

How to confirm (reproduce both directions)

Two standalone probes against the same OpenSSL corosio links; also worth
repeating for WolfSSL with wolfSSL_get_session / wolfSSL_set_session /
wolfSSL_session_reused.

  1. Socket-pair baseline (expected: resumes). Give the server a session
    id context (SSL_CTX_set_session_id_context) and a session cache; connect
    two SSL objects over socketpair with SSL_set_fd. Round 1: full
    handshake, then sess = SSL_get1_session(client) and assert
    SSL_SESSION_is_resumable(sess) == 1 (for TLS 1.3, do a short
    SSL_read/SSL_peek on the client first so the ticket is processed).
    Round 2: new SSL objects from the same SSL_CTXs,
    SSL_set_session(client, sess), handshake, assert
    SSL_session_reused(client) == 1.

  2. Memory-BIO reproduction (expected: does not resume). Same logic, but
    wire each SSL to a BIO_new_bio_pair and shuttle bytes by hand (mirror
    flush_output / read_input). Round 1 yields a non-resumable session;
    round 2 reports SSL_session_reused == 0.

A prior investigation ran both and saw exactly this split.

Backend capability (verified on the linked libraries)

  • OpenSSL: full — SSL_get1_session (refcounted), SSL_set_session,
    SSL_session_reused, TLS 1.2 IDs and TLS 1.3 tickets, cache modes. Server
    accept needs a fixed SSL_CTX_set_session_id_context.
  • WolfSSL (CI/vcpkg build has HAVE_SESSION_TICKET; the distro/minimal
    build does not, and OPENSSL_EXTRA is off there):
    • native wolfSSL_get_session / wolfSSL_set_session /
      wolfSSL_session_reused are available → TLS 1.2 session-ID resumption
      works and is observable
      ;
    • no TLS 1.3 tickets without HAVE_SESSION_TICKET;
    • wolfSSL_get1_session / owning-dup and session serialization need
      OPENSSL_EXTRA (absent in the minimal build), so a WolfSSL tls_session
      is non-owning and valid only for its originating tls_context's
      lifetime.

Way forward

  1. Root-cause the transport first (shared with OCSP stapling). Confirm the
    hypothesis by adding a bounded post-handshake read_input() pump (so TLS
    1.3 NewSessionTicket records are processed) and ensuring coupled streams
    drain each other's trailing flight. Re-run the memory-BIO probe until
    round-2 SSL_session_reused == 1.
  2. Re-introduce the API per the plan file: opaque tls_session,
    session() / set_session() / session_reused() (base defaults =
    empty/false so any backend compiles honestly), OpenSSL owning holder
    (SSL_SESSION_free) + server id-context, WolfSSL non-owning holder gated
    by a wolfssl_supports_session_resumption() capability query. Best-effort:
    never fail closed.
  3. Test across backends, TLS 1.2 pinned (deterministic and the only mode
    the minimal WolfSSL can resume): round 1 captures a session, round 2 with
    set_session reports session_reused() and transfers data. Base-class
    negatives: empty/false before a handshake and for a fresh unshared
    handshake.

Deferred sub-items (call out; do not silently skip)

  • TLS 1.3 client ticket capture timing (SSL_CTX_sess_set_new_cb).
  • Server accept-resumption tuning (cache size/mode, TLS 1.3 server tickets).
  • Serializable sessions (i2d/d2i, OpenSSL-only) for cross-process caches.

Acceptance criteria

  • The memory-BIO probe resumes (round-2 SSL_session_reused == 1) on
    OpenSSL and on a capable WolfSSL (TLS 1.2).
  • tls_session + accessors re-introduced; best-effort, no fail-closed.
  • WolfSSL path capability-gated; documented non-owning/TLS-1.2 lifetime
    constraint.
  • Cross-backend TLS 1.2 test added; docs updated.

Pointers

  • ~/.claude/plans/create-a-plan-to-keen-lamport.md — full design/plan
  • src/openssl/src/openssl_stream.cpp:41 — BIO data-flow banner
  • src/openssl/src/openssl_stream.cppdo_handshake (~863, the ret == 1
    branch is the suspect), flush_output (~668), read_input (~699)
  • tasks/tls/ocsp-stapling.md — same transport root cause

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    Status
    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions