Skip to content

feat: resume TLS sessions instead of a full handshake per connection - #36

Open
shreemaan-abhishek wants to merge 2 commits into
mainfrom
feat/tls-session-resumption
Open

feat: resume TLS sessions instead of a full handshake per connection#36
shreemaan-abhishek wants to merge 2 commits into
mainfrom
feat/tls-session-resumption

Conversation

@shreemaan-abhishek

@shreemaan-abhishek shreemaan-abhishek commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Closes #34.

Every https connection paid a full handshake, even against a peer this worker
handshook with seconds earlier and that was offering to resume. Nothing stored a
session and nothing offered one.

A per-worker cache now holds one session per TLS peer. A fresh connection offers
the session for its key before the handshake, and the entry is refilled when the
peer hands a new session back.

The design questions from the issue

1. Where does the cache live? Its own cache, on main_conf, rather than
OpenSSL's client-side store. SSL_SESS_CACHE_CLIENT|SSL_SESS_CACHE_NO_INTERNAL
plus a new_session_cb gives the callback without a second copy of every
session, and keeps eviction and expiry in one place. A client never looks a
session up by itself anyway, so the internal store would only have grown.

2. What is the cache key, and is it the pool key? The key is the verify
mode, the peer address, the SNI and the trusted CA. That is the pool key's
tagging with one difference: the pool groups by the caller's pool name, and a
session is keyed by the address actually connected to, so a pool spanning peers
still resumes each one only against itself. Every input the handshake was judged
on is in the key, which is what matters, since a resumed handshake skips the
certificate exchange and answers SSL_get_verify_result() from the session.

3. Interaction with #33. The verify flag is part of the key, so a session
established under verify-off is never offered to a verify-on request. The CA is
folded away with verify off, matching what #33 did to the SSL_CTX cache:
nothing reads it in that state, and keying on it would split the cache per
caller for nothing. TEST 4 covers this, with the same peer and the same SNI on
both sides so the verify flag is the only thing telling the two apart.

4. TLS 1.3 tickets. Sessions are taken from the session callback, not at
handshake completion, so a ticket that arrives after the handshake is picked up.
TEST 1 runs against a TLSv1.3-only server and TEST 2 against a
TLSv1.2-only one, so the two delivery paths are covered separately rather than
by whatever the peer happened to negotiate.

5. Opt-out. On by default, ssl_session_reuse = false turns it off on both
entry points, as proxy_ssl_session_reuse does. The observable change is that a
resumed handshake does not re-send the peer certificate; nothing here reads it
outside the verify path, which reads it from the session.

6. Eviction and memory. 256 peers per worker, least recently used evicted.
An entry past its expiry is dropped on the way out rather than offered.

One thing the issue did not raise: a session is published only once the
handshake has passed the checks the caller asked for. On TLS 1.2 the callback
fires before the certificate is judged, so a session arriving early is held on
the connection and committed by the handshake handler. A peer rejected for a bad
chain or a name mismatch leaves nothing behind for the next request.

Results

Connection per request over TLS, 300 sequential requests each way, resumption
off then on, same build and same peer:

req/s
resumption off ~1510
resumption on ~4370

2.8x to 3.0x across three runs. Both ends run in one worker there, so the
figure includes the handshake work the server no longer does; the shape is the
tlsshort one.

Tests

t/016-tls-session-resumption.t, seven blocks, reading $ssl_session_reused
off the upstream:

  • TLS 1.3 and TLS 1.2 each resume a second connection.
  • ssl_session_reuse = false keeps every handshake full.
  • A verify-off session is not offered to a verify-on request, and a verify-on
    request resumes its own.
  • The stateful object resumes across connections.
  • Sessions do not cross SNI.
  • 260 peers against a cache of 256: the first is gone, the last resumes.

prove -r t passes on the default llhttp backend, 401 tests. The
hand-written fallback backend builds and its TLS files pass; its pre-existing
failures in 001, 007, 010, 011 and 014 are the same before and after
this change.

Summary by CodeRabbit

  • New Features

    • HTTPS connections now reuse TLS sessions by default, improving repeat-connection performance.
    • Session reuse is supported for both one-time requests and persistent connections.
    • TLS session reuse can be disabled with ssl_session_reuse = false.
    • TLS 1.2 and TLS 1.3 are supported, with verification and SNI settings isolated.
    • Session caching automatically handles expiration and limits cache size using least-recently-used eviction.
  • Documentation

    • Added guidance on session reuse behavior, caching, expiration, and configuration.

Every https connection started a full handshake, even against a peer this
worker handshook with seconds earlier and that was offering to resume. On the
connection-per-request TLS shape the handshake is most of the request cost.

A per-worker cache holds one session per TLS peer, up to 256, evicting the
least recently used and dropping expired ones on the way out. A fresh
connection offers the session for its key before the handshake; OpenSSL reports
new sessions through the session callback, which is what makes TLS 1.3 resume
too, since its tickets arrive after the handshake rather than during it.

The cache key is the verify mode, the peer address, the SNI and the trusted CA
- every input the original handshake was judged on, since a resumed one skips
the certificate exchange and answers with the verify result stored in the
session. A session is published only once the handshake has passed the checks
the caller asked for, so a rejected peer leaves nothing behind.

Resumption is on by default and both entry points take ssl_session_reuse =
false to turn it off, as nginx's proxy_ssl_session_reuse does.
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3c57f0ab-c85a-4e3c-b32a-386e3f9ed0bd

📥 Commits

Reviewing files that changed from the base of the PR and between ec07f3e and d1c4429.

📒 Files selected for processing (3)
  • README.md
  • src/ngx_http_ffi_client_request.c
  • t/016-tls-session-resumption.t
🚧 Files skipped from review as they are similar to previous changes (2)
  • README.md
  • src/ngx_http_ffi_client_request.c

📝 Walkthrough

Walkthrough

Adds opt-out TLS session reuse for stateless and stateful HTTPS clients. A worker-level 256-entry LRU cache stores resumable sessions with verification, peer, SNI, and CA isolation. Tests cover TLS 1.2/1.3 reuse, expiry, eviction, and handshake acceptance.

Changes

TLS session resumption

Layer / File(s) Summary
Session reuse contracts
config, src/ngx_http_ffi_client.h, lib/resty/ngx_http_ffi_client.lua
Adds the ssl_session_reuse option, operation state, cache APIs, worker cache configuration, and addon source wiring for both client entry points.
Worker session cache
src/ngx_http_ffi_client_ssl_session.c
Implements worker-level session storage with verification-aware keys, SNI and peer isolation, expiration, LRU eviction, collision-safe lookup, OpenSSL ownership, and TLS 1.2/1.3 callbacks.
Handshake integration
src/ngx_http_ffi_client_request.c
Enables session caching, offers cached sessions before handshakes, and commits sessions after accepted handshakes.
Protocol and cache validation
t/016-tls-session-resumption.t, README.md
Tests TLS 1.2/1.3 reuse, disabled reuse, verification and SNI isolation, stateful connections, and 256-entry eviction. Documents the behavior and configuration.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ngx_http_ffi_client_lua
  participant ngx_http_ffi_client_request
  participant ngx_http_ffi_client_ssl_session
  participant OpenSSL
  ngx_http_ffi_client_lua->>ngx_http_ffi_client_request: Start HTTPS request with ssl_session_reuse
  ngx_http_ffi_client_request->>ngx_http_ffi_client_ssl_session: Offer cached session
  ngx_http_ffi_client_ssl_session-->>ngx_http_ffi_client_request: Return session or cache miss
  ngx_http_ffi_client_request->>OpenSSL: Perform TLS handshake
  OpenSSL-->>ngx_http_ffi_client_request: Accept handshake and provide session
  ngx_http_ffi_client_request->>ngx_http_ffi_client_ssl_session: Commit accepted session
Loading

Possibly related PRs

Suggested reviewers: membphis

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
E2e Test Quality Review ⚠️ Warning The suite covers TLS 1.2/1.3 and LRU behavior, but production code explicitly ignores SSL_set_session() failure and several E2E helper results remain unchecked. Check SSL_set_session() and cache-setup return values, log or handle failures, and add stateful opt-out, expiry, and rejected-handshake cache tests.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: TLS session resumption replaces full handshakes.
Linked Issues check ✅ Passed The implementation satisfies issue #34 with bounded LRU caching, strict keys, TLS 1.2/1.3 support, opt-out control, and resumption tests.
Out of Scope Changes check ✅ Passed The documentation, configuration, implementation, and tests directly support the TLS session resumption objectives in issue #34.
Security Check ✅ Passed No security findings: no secret logging or database writes; no authorization/ownership paths; TLS uses TLS 1.2+, isolates sessions by verification, peer, SNI, and CA.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/tls-session-resumption

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/ngx_http_ffi_client_request.c (1)

1874-1880: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider treating session-reuse setup failure as non-fatal.

ngx_http_ffi_client_ssl_session_offer() returns NGX_ERROR for allocation failures and for cache creation failure. Those conditions do not prevent a normal handshake. As written, they fail the whole request.

Session resumption is an optimization. The session module already applies that reasoning to a refused session: src/ngx_http_ffi_client_ssl_session.c line 172 ignores the result of ngx_ssl_set_session() because a session the peer forgot only costs a full handshake. The same argument applies to setup failure.

Log the failure and continue with a full handshake instead.

♻️ Proposed change to degrade instead of failing
-    if (op->ssl_session_reuse
-        && ngx_http_ffi_client_ssl_session_offer(op, c) != NGX_OK)
-    {
-        ngx_http_ffi_client_finalize(op, NGX_ERROR,
-                                     "failed to set up TLS session reuse");
-        return NGX_ERROR;
-    }
+    /* resumption is an optimization: a setup failure only costs a full
+     * handshake, so it must not fail the request */
+    if (op->ssl_session_reuse
+        && ngx_http_ffi_client_ssl_session_offer(op, c) != NGX_OK)
+    {
+        ngx_log_error(NGX_LOG_WARN, c->log, 0,
+                      "ngx_http_ffi_client: TLS session reuse unavailable");
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ngx_http_ffi_client_request.c` around lines 1874 - 1880, Update the
ssl_session_reuse handling around ngx_http_ffi_client_ssl_session_offer() so
NGX_ERROR is logged but does not call ngx_http_ffi_client_finalize() or return
NGX_ERROR. Continue the request into the normal TLS handshake, allowing it to
proceed without session reuse.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@README.md`:
- Around line 108-109: Update the HTTPS connection explanation in the README to
distinguish TLS versions: qualify TLS 1.2 session resumption as reducing two
round trips to one only when the server accepts the session, and state that
normal TLS 1.3 handshakes and TLS 1.3 PSK resumption use one round trip without
early data. Describe TLS 1.3 resumption as omitting certificate transmission
rather than universally reducing handshake latency.

In `@t/016-tls-session-resumption.t`:
- Around line 395-407: Update the TLS session resumption test to populate
exactly 256 cache entries, access h1 again to promote it in the LRU order, then
add h257. Change the assertions around the request/response flow so h1 and h257
resume successfully while h2 misses, thereby validating both the 256-entry
capacity and LRU promotion behavior.

---

Nitpick comments:
In `@src/ngx_http_ffi_client_request.c`:
- Around line 1874-1880: Update the ssl_session_reuse handling around
ngx_http_ffi_client_ssl_session_offer() so NGX_ERROR is logged but does not call
ngx_http_ffi_client_finalize() or return NGX_ERROR. Continue the request into
the normal TLS handshake, allowing it to proceed without session reuse.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 023c5ce9-e576-4f21-ac1b-a2c46d2f0b08

📥 Commits

Reviewing files that changed from the base of the PR and between 124eaff and ec07f3e.

📒 Files selected for processing (7)
  • README.md
  • config
  • lib/resty/ngx_http_ffi_client.lua
  • src/ngx_http_ffi_client.h
  • src/ngx_http_ffi_client_request.c
  • src/ngx_http_ffi_client_ssl_session.c
  • t/016-tls-session-resumption.t

Comment thread README.md Outdated
Comment on lines +108 to +109
An https connection offers the server the session from an earlier handshake with
the same peer, so it costs one round trip instead of two and no certificate work.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- README.md lines 90-125 ---'
sed -n '90,125p' README.md
printf '%s\n' '--- session and handshake references ---'
rg -n -i 'session|handshake|TLS|https connection|certificate' --glob '!node_modules' --glob '!dist' --glob '!build' .
printf '%s\n' '--- tracked files near likely client/session implementation ---'
git ls-files | rg -i 'session|cache|tls|https|client|connection|readme'

Repository: api7/ngx_http_ffi_client

Length of output: 36104


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- README.md TLS section ---'
sed -n '106,132p' README.md
printf '%s\n' '--- session offer and handshake implementation ---'
sed -n '108,218p' src/ngx_http_ffi_client_ssl_session.c
sed -n '1840,1960p' src/ngx_http_ffi_client_request.c
printf '%s\n' '--- TLS resumption tests ---'
sed -n '1,50p' t/016-tls-session-resumption.t
sed -n '100,205p' t/016-tls-session-resumption.t
printf '%s\n' '--- static path verifier ---'
python3 - <<'PY'
from pathlib import Path
readme = Path("README.md").read_text()
source = Path("src/ngx_http_ffi_client_request.c").read_text()
session = Path("src/ngx_http_ffi_client_ssl_session.c").read_text()
tests = Path("t/016-tls-session-resumption.t").read_text()

checks = {
    "README claims unconditional one-vs-two RTT": (
        "so it costs one round trip instead of two" in readme
    ),
    "client offers cached session before handshake": (
        source.index("ngx_http_ffi_client_ssl_session_offer(op, c)")
        < source.index("rc = ngx_ssl_handshake(c)")
    ),
    "OpenSSL handshake result controls fallback/failure path": (
        "rc = ngx_ssl_handshake(c)" in source
        and "if (!c->ssl->handshaked)" in source
    ),
    "TLS 1.3 and TLS 1.2 are both exercised": (
        "ssl_protocols TLSv1.3;" in tests
        and "ssl_protocols TLSv1.2;" in tests
    ),
    "tests explicitly cover both protocol resumptions": (
        "TLS 1.3 resumes the session" in tests
        and "TLS 1.2 resumes the session" in tests
    ),
    "README states resumed handshake skips certificate exchange": (
        "A resumed handshake skips the certificate exchange" in readme
    ),
}
for name, passed in checks.items():
    print(f"{'PASS' if passed else 'FAIL'}: {name}")
PY

Repository: api7/ngx_http_ffi_client

Length of output: 13203


🌐 Web query:

RFC 8446 TLS 1.3 full handshake PSK resumption round trips certificate exchange TLS 1.2 abbreviated handshake

💡 Result:

In TLS 1.3 (RFC 8446), the handshake design significantly reduces latency compared to previous versions [1][2]. A standard TLS 1.3 full handshake requires one round trip (1-RTT) [1][3], whereas a TLS 1.3 PSK (Pre-Shared Key) resumption handshake can achieve zero round trips (0-RTT) for application data [1][2][3]. Full Handshake vs. PSK Resumption In a TLS 1.3 full handshake, the client sends a ClientHello with key shares, and the server responds with a ServerHello, EncryptedExtensions, Certificate, CertificateVerify, and Finished message [2]. Because the server is authenticating via a certificate, it must send the Certificate and CertificateVerify messages [4][5]. In contrast, a PSK resumption handshake (often called "resumption" or "PSK mode") uses a shared secret from a previous connection [4][3]. When a server authenticates via a PSK, it does not send Certificate or CertificateVerify messages [4][5]. If 0-RTT is enabled, the client can send encrypted application data in its first flight (the ClientHello), effectively achieving zero round-trip latency [1][2][3]. Comparison with TLS 1.2 TLS 1.2 typically required two round trips for a full handshake [1][6]. While TLS 1.2 supported "abbreviated handshakes" (session resumption via Session IDs or Session Tickets), these still required a round trip [6][3]. TLS 1.3 obsoletes the older TLS 1.2 session ID and ticket mechanisms [4][7], replacing them with the integrated PSK resumption framework [6][3]. Key Differences Summary - Full Handshake: TLS 1.3 requires 1-RTT, compared to 2-RTT in TLS 1.2 [1][6][2]. - Certificate Exchange: Standard TLS 1.3 full handshakes include Certificate and CertificateVerify messages [2][5]. These are omitted in TLS 1.3 PSK resumption because authentication is performed via the shared secret [4][5]. - Resumption: TLS 1.2 abbreviated handshakes (1-RTT) are superseded by TLS 1.3 PSK resumption, which supports optional 0-RTT for even lower latency [1][6][3]. 0-RTT data, however, carries a risk of replay attacks [1][2][3].

Citations:


Qualify the TLS handshake latency statement.

If the server accepts the offered session, TLS 1.2 resumption reduces the handshake from two round trips to one. A normal TLS 1.3 handshake and TLS 1.3 PSK resumption both use one round trip without early data. Describe TLS 1.3 resumption as omitting certificate transmission, not as reducing every connection from two round trips to one. (datatracker.ietf.org)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 108 - 109, Update the HTTPS connection explanation in
the README to distinguish TLS versions: qualify TLS 1.2 session resumption as
reducing two round trips to one only when the server accepts the session, and
state that normal TLS 1.3 handshakes and TLS 1.3 PSK resumption use one round
trip without early data. Describe TLS 1.3 resumption as omitting certificate
transmission rather than universally reducing handshake latency.

Comment thread t/016-tls-session-resumption.t Outdated
Comment on lines +395 to +407
for i = 1, 260 do
if not get("h" .. i .. ".test") then return end
end

ngx.say("first ", get("h1.test"))
ngx.say("last ", get("h260.test"))
}
}
--- request
GET /t
--- response_body
first .
last r

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the capacity and LRU assertion discriminating.

The current assertions can pass with a two-entry FIFO cache. h1 becomes a miss after overflow and h260 remains a hit in that implementation.

Populate exactly 256 entries. Reuse h1 before adding h257. Then require h1 and h257 to resume, and require h2 to miss. This detects both an incorrect capacity and missing LRU promotion.

Proposed test change
-            for i = 1, 260 do
+            for i = 1, 256 do
                 if not get("h" .. i .. ".test") then return end
             end
 
-            ngx.say("first ", get("h1.test"))
-            ngx.say("last ", get("h260.test"))
+            ngx.say("promoted ", get("h1.test"))
+            ngx.say("new ", get("h257.test"))
+            ngx.say("first ", get("h1.test"))
+            ngx.say("second ", get("h2.test"))
- first .
- last r
+ promoted r
+ new .
+ first r
+ second .

Based on PR objectives: the cache must be bounded at 256 peers and evict by LRU.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for i = 1, 260 do
if not get("h" .. i .. ".test") then return end
end
ngx.say("first ", get("h1.test"))
ngx.say("last ", get("h260.test"))
}
}
--- request
GET /t
--- response_body
first .
last r
for i = 1, 256 do
if not get("h" .. i .. ".test") then return end
end
ngx.say("promoted ", get("h1.test"))
ngx.say("new ", get("h257.test"))
ngx.say("first ", get("h1.test"))
ngx.say("second ", get("h2.test"))
}
}
--- request
GET /t
--- response_body
promoted r
new .
first r
second .
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@t/016-tls-session-resumption.t` around lines 395 - 407, Update the TLS
session resumption test to populate exactly 256 cache entries, access h1 again
to promote it in the LRU order, then add h257. Change the assertions around the
request/response flow so h1 and h257 resume successfully while h2 misses,
thereby validating both the 256-entry capacity and LRU promotion behavior.

@shreemaan-abhishek shreemaan-abhishek self-assigned this Aug 6, 2026
Nothing ngx_http_ffi_client_ssl_session_offer() can fail on stops a normal
handshake, and the full handshake it falls back to is what the connection would
have done anyway, so a setup failure is logged rather than failing the request.

The cache test now fills to exactly the cap, uses its oldest peer, and adds one
more: the peer that goes has to be the second oldest, which a cache that never
promotes on a hit would keep while dropping the one just used.
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.

TLS: no session resumption — every https connection pays a full handshake

1 participant