From 1b48d14b4feac1f8704f165026a27baf776c3e69 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Sun, 9 Aug 2026 23:34:22 +0000 Subject: [PATCH] feat(core): ALPN configuration and TLS visibility on Request/WebSocketSession MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TlsConfig.applicationProtocols(String...) sets the listener's negotiable ALPN protocol list via SSLParameters, inherited by every accepted socket like clientAuth — works on both keystore() and ofContext(), untouched unless called. Enables TLS-ALPN-01 (RFC 8737) style on-demand cert issuance: a custom KeyManager can read the already-resolved protocol via engine/socket getHandshakeApplicationProtocol() inside chooseEngineServerAlias/chooseServerAlias, since ALPN is resolved during ClientHello/ServerHello, always before Certificate production. - Request gains isSecure()/sslSession(), threaded through RequestParser from the accepted SSLSocket exactly like remoteAddress() — reference-only, zero per-request allocation. sslSession() defers to SSLSocket#getSession() lazily, so it's a cached-field read (handshake already completed by the time a handler can call it), never a forced handshake. - WebSocketSession.isSecure()/sslSession() delegate to the upgrading Request rather than tracking the socket a second time. - Documents TLS end-to-end in README.md (listeners, TlsConfig, SNI, ALPN, mTLS, Request/WebSocketSession accessors). Co-Authored-By: Claude Sonnet 5 --- README.md | 89 ++++++ .../java/dev/relism/flash/HttpServer.java | 4 +- .../java/dev/relism/flash/RequestParser.java | 17 +- .../java/dev/relism/flash/models/Request.java | 46 ++- .../java/dev/relism/flash/tls/TlsConfig.java | 57 ++-- .../flash/websocket/WebSocketSession.java | 13 + .../dev/relism/flash/HttpServerTlsTest.java | 269 +++++++++++++++++- 7 files changed, 468 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 8f0560a..d317d8a 100644 --- a/README.md +++ b/README.md @@ -163,8 +163,96 @@ app.onException((ex, req, res) -> { |---|---|---| | `port` | — | TCP port to bind | | `host` | `"0.0.0.0"` | Bind address | +| `tls` | `null` | TLS for the default listener — see [TLS](#tls) | +| `listeners` | `[]` | Multiple bind targets (port + host + optional TLS) on one app — see [TLS](#tls) | | `maxHeaderBufferSize` | `65536` | Max size of the header buffer (bytes) | +## TLS + +HTTPS and WSS are a transport-layer concern only: once a listener is bound, the accepted +`Socket` is either plain or an `SSLSocket` indistinguishably from `HttpServer`'s point of view +onward — the request parser, router, and WebSocket upgrade never branch on it. WSS is therefore +not a separate feature; it's a WebSocket upgrade running over whatever transport it was handed. + +### Quick start + +```java +FlashApp.create(FlashConfiguration.builder() + .port(443) + .tls(TlsConfig.keystore(Path.of("cert.p12"), "changeit")) + .build()) + .get("/ping", (req, res) -> "pong") // HTTPS + .ws("/live", handler) // WSS, same route API + .start(); +``` + +### Multiple listeners + +One app can bind any number of ports, each independently plain or TLS: + +```java +FlashApp.create(FlashConfiguration.builder() + .listener(new FlashConfiguration.Listener(80)) // plain + .listener(new FlashConfiguration.Listener(443, TlsConfig.keystore(cert, pass))) // TLS + .build()); +``` + +A non-empty `listeners` list takes precedence over the top-level `port`/`host`/`tls` fields. +Each listener gets its own accept threads; the router, WS router, and virtual-thread executor +are shared by all of them — one app, N ports. + +### `TlsConfig` + +| Factory | Use | +|---|---| +| `TlsConfig.keystore(Path, String)` | Builds the `SSLContext` from a PKCS12/JKS keystore (type guessed from the extension). Pins `TLSv1.2`/`TLSv1.3` as enabled protocols; cipher suites are left at the JDK's own curated default. | +| `TlsConfig.ofContext(SSLContext)` | Escape hatch — the given `SSLContext` is used exactly as built. Flash never calls `setSSLParameters` on this path beyond what you explicitly request via `clientAuth`/`applicationProtocols`, so anything else you configured (custom `KeyManager`, ALPN, cipher suites) is authoritative. | + +Chainable on either factory: + +```java +TlsConfig.keystore(cert, pass) + .clientAuth(ClientAuth.REQUIRE) // mTLS: NONE (default) | OPTIONAL | REQUIRE + .applicationProtocols("acme-tls/1", "http/1.1") // ALPN, in preference order +``` + +**SNI** falls out of `keystore()` for free: a keystore holding more than one certificate entry +is matched against the requested hostname by each certificate's SAN (falling back to CN) — no +per-hostname config. The first entry in the keystore is the default when SNI is absent or +matches nothing (same convention as nginx/HAProxy's `default_server`). + +**ALPN and custom certificate selection** (e.g. TLS-ALPN-01 / RFC 8737 for on-demand ACME +issuance): ALPN is resolved while consuming `ClientHello`/producing `ServerHello`, which always +precedes `Certificate` production. A custom `X509ExtendedKeyManager` passed via `ofContext` +can therefore read `engine.getHandshakeApplicationProtocol()` (or +`((SSLSocket) socket).getHandshakeApplicationProtocol()`) inside +`chooseEngineServerAlias`/`chooseServerAlias` — the negotiated protocol is already resolved by +then, so the certificate decision can key off it. + +**mTLS with a private CA**: `clientAuth(...)` only requests/requires a client certificate; +`keystore()` deliberately doesn't expose a way to configure which CAs are trusted for that +certificate (it uses the JDK default trust store). For a private CA, build the `SSLContext` +yourself with a `TrustManagerFactory` and use `ofContext(...)`. + +### Reading TLS info from a request + +```java +app.get("/whoami", (req, res) -> { + if (!req.isSecure()) return "plain"; + SSLSession session = req.sslSession(); // null iff !isSecure() + X509Certificate peer = (X509Certificate) session.getPeerCertificates()[0]; // mTLS only + return session.getCipherSuite() + " / " + session.getProtocol(); +}); +``` + +`Request.isSecure()` / `Request.sslSession()` cost nothing extra per request: the `SSLSocket` +reference is threaded through once per connection (same mechanism as `remoteAddress()`), and +`sslSession()` only calls `SSLSocket#getSession()` — a cached-field read once the handshake +that got the request this far has already completed, never a forced handshake. + +`WebSocketSession` mirrors this exactly (`isSecure()`, `sslSession()`) by delegating to the +upgrading `Request` — no separate TLS state is tracked for WS. + ## Architecture ``` @@ -181,6 +269,7 @@ ServerSocket.accept() - **Zero-allocation router** — `FastPathRouterImpl` uses `fpr-core`, a byte-level FSM that matches on `METHOD + path` bytes with no per-request allocation. - **Keep-alive** — `RequestParser` reuses its header buffer across requests on the same connection. - **Chunked transfer** — both chunked request bodies (decoded via `ChunkedInputStream`) and chunked response bodies are supported. +- **TLS is transport-only** — see [TLS](#tls). Listeners bind either a plain `ServerSocket` or an `SSLServerSocket`; nothing downstream of `accept()` branches on which. ## Build & test diff --git a/flash/src/main/java/dev/relism/flash/HttpServer.java b/flash/src/main/java/dev/relism/flash/HttpServer.java index 1c76172..d352134 100644 --- a/flash/src/main/java/dev/relism/flash/HttpServer.java +++ b/flash/src/main/java/dev/relism/flash/HttpServer.java @@ -19,6 +19,7 @@ import dev.relism.fpr.core.ByteView; import lombok.extern.slf4j.Slf4j; import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLSocket; import java.io.*; import java.net.InetSocketAddress; @@ -296,7 +297,8 @@ class HttpServer implements ServerHandle { RequestParser parser = new RequestParser( configuration.getMaxHeaderBufferSize(), - (InetSocketAddress) socket.getRemoteSocketAddress()); + (InetSocketAddress) socket.getRemoteSocketAddress(), + socket instanceof SSLSocket sslSocket ? sslSocket : null); while (!stopped) { Request request = parser.parse(in); diff --git a/flash/src/main/java/dev/relism/flash/RequestParser.java b/flash/src/main/java/dev/relism/flash/RequestParser.java index 23e8734..1a50a25 100644 --- a/flash/src/main/java/dev/relism/flash/RequestParser.java +++ b/flash/src/main/java/dev/relism/flash/RequestParser.java @@ -8,6 +8,8 @@ import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews; import lombok.extern.slf4j.Slf4j; +import javax.net.ssl.SSLSocket; + import java.io.IOException; import java.io.InputStream; import java.net.InetSocketAddress; @@ -40,6 +42,7 @@ public class RequestParser { private final int maxHeaderBufferSize; private final InetSocketAddress remoteAddress; + private final SSLSocket sslSocket; private final HeaderMap headerMap = new HeaderMap(); private byte[] buffer; @@ -48,11 +51,17 @@ public class RequestParser { private int bufBase = 0; private int bufLen = 0; - public RequestParser() { this(64 * 1024, null); } - public RequestParser(int maxHeaderBufferSize) { this(maxHeaderBufferSize, null); } + public RequestParser() { this(64 * 1024, null, null); } + public RequestParser(int maxHeaderBufferSize) { this(maxHeaderBufferSize, null, null); } public RequestParser(int maxHeaderBufferSize, InetSocketAddress remoteAddress) { + this(maxHeaderBufferSize, remoteAddress, null); + } + + /** {@code sslSocket} is {@code null} for a plain connection — see {@link Request#isSecure()}. */ + public RequestParser(int maxHeaderBufferSize, InetSocketAddress remoteAddress, SSLSocket sslSocket) { this.maxHeaderBufferSize = maxHeaderBufferSize; this.remoteAddress = remoteAddress; + this.sslSocket = sslSocket; this.buffer = new byte[Math.min(INITIAL_BUFFER_SIZE, maxHeaderBufferSize)]; } @@ -177,9 +186,9 @@ public class RequestParser { if (isChunked) { return Request.forParsed(requestLine, new ChunkedInputStream(in, buffer, bodyStart, preBufLen), - -1L, null, 0, 0, remoteAddress); + -1L, null, 0, 0, remoteAddress, sslSocket); } - return Request.forParsed(requestLine, in, contentLength, buffer, bodyStart, preBufLen, remoteAddress); + return Request.forParsed(requestLine, in, contentLength, buffer, bodyStart, preBufLen, remoteAddress, sslSocket); } // ── Buffer scanning utilities (hot path — keep branch-free where possible) ── diff --git a/flash/src/main/java/dev/relism/flash/models/Request.java b/flash/src/main/java/dev/relism/flash/models/Request.java index bbbf8e1..2d80b72 100644 --- a/flash/src/main/java/dev/relism/flash/models/Request.java +++ b/flash/src/main/java/dev/relism/flash/models/Request.java @@ -9,6 +9,9 @@ import lombok.ToString; import lombok.Value; import lombok.experimental.NonFinal; +import javax.net.ssl.SSLSession; +import javax.net.ssl.SSLSocket; + import java.io.InputStream; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; @@ -60,12 +63,30 @@ public class Request { @ToString.Exclude InetSocketAddress remoteAddress; - private Request(RequestLine requestLine, RequestBody body, InetSocketAddress remoteAddress) { + /** + * The accepted socket for this connection, or {@code null} if plain HTTP — set once per + * connection by {@link RequestParser}, same lifetime and reference-only cost as + * {@link #remoteAddress}. Every request on the same keep-alive connection shares the + * identical instance. + * + *

Never exposed directly: {@link #isSecure()} and {@link #sslSession()} are the public + * surface. {@link javax.net.ssl.SSLSocket#getSession()} is deferred to {@link #sslSession()} + * rather than called here — by the time a handler can call it, the handshake this connection + * needed to reach the handler has already completed, so it is a cached-field read, never a + * forced handshake. + */ + @Getter(lombok.AccessLevel.NONE) + @EqualsAndHashCode.Exclude + @ToString.Exclude + SSLSocket sslSocket; + + private Request(RequestLine requestLine, RequestBody body, InetSocketAddress remoteAddress, SSLSocket sslSocket) { this.requestLine = requestLine; this.body = body; this.pathParams = null; this.queryParams = null; this.remoteAddress = remoteAddress; + this.sslSocket = sslSocket; } /** @@ -75,19 +96,19 @@ public class Request { */ void setPathParams(PathParams p) { this.pathParams = p; } - /** Test / manual constructor — {@code remoteAddress()} returns {@code null}. */ + /** Test / manual constructor — {@code remoteAddress()} returns {@code null}, {@code isSecure()} is {@code false}. */ public Request(RequestLine requestLine, byte[] body) { - this(requestLine, RequestBody.of(body), null); + this(requestLine, RequestBody.of(body), null, null); } public static Request forParsed(RequestLine requestLine, InputStream stream, long contentLength, byte[] headerBuf, int bodyStart, int preBufLen, - InetSocketAddress remoteAddress) { + InetSocketAddress remoteAddress, SSLSocket sslSocket) { RequestBody rb = contentLength > 0 ? new RequestBody(stream, contentLength, headerBuf, bodyStart, preBufLen) : contentLength == 0 ? RequestBody.empty() : /* chunked */ new RequestBody(stream, -1L, null, 0, 0); - return new Request(requestLine, rb, remoteAddress); + return new Request(requestLine, rb, remoteAddress, sslSocket); } // ── Request line ────────────────────────────────────────────────────────── @@ -170,6 +191,21 @@ public class Request { */ public InetSocketAddress remoteAddress() { return remoteAddress; } + // ── TLS ─────────────────────────────────────────────────────────────────── + + /** Whether this request arrived over TLS (HTTPS). */ + public boolean isSecure() { return sslSocket != null; } + + /** + * Returns the TLS session for this connection, or {@code null} for plain HTTP. + * Gives access to {@link SSLSession#getPeerCertificates()} (mTLS — the caller's certificate + * chain, if {@code TlsConfig.clientAuth} required or requested one), and to + * {@link SSLSession#getCipherSuite()} / {@link SSLSession#getProtocol()} for logging and + * diagnostics. {@code null} rather than throwing when {@link #isSecure()} is {@code false} — + * check that first, or just null-check the result. + */ + public SSLSession sslSession() { return sslSocket != null ? sslSocket.getSession() : null; } + // ── Body ────────────────────────────────────────────────────────────────── /** diff --git a/flash/src/main/java/dev/relism/flash/tls/TlsConfig.java b/flash/src/main/java/dev/relism/flash/tls/TlsConfig.java index 2091dc8..16935c2 100644 --- a/flash/src/main/java/dev/relism/flash/tls/TlsConfig.java +++ b/flash/src/main/java/dev/relism/flash/tls/TlsConfig.java @@ -27,14 +27,24 @@ import java.security.KeyStore; * JDK's own curated default, which each JDK security release keeps current — Flash does * not maintain its own suite allow-list. *

  • {@link #ofContext(SSLContext)} — escape hatch. The given {@link SSLContext} is used - * exactly as built: Flash never calls {@code setSSLParameters} on this path, so protocols, - * cipher suites, and ALPN (e.g. {@code acme-tls/1} for TLS-ALPN-01) you configured on it - * are 100% authoritative.
  • + * exactly as built: Flash never calls {@code setSSLParameters} on this path unless you + * explicitly call {@link #applicationProtocols} or {@link #clientAuth} yourself, so + * anything else you configured on it is 100% authoritative. * * - *

    {@link #clientAuth(ClientAuth)} applies on either path — it is an explicit instruction - * through this API, not a Flash-chosen default, so it is only ever applied when called. The - * {@link ClientAuth#NONE} default makes no client-auth call at all, on either path. + *

    {@link #clientAuth(ClientAuth)} and {@link #applicationProtocols(String...)} apply on + * either path — they are explicit instructions through this API, not Flash-chosen defaults, so + * each is only ever applied when called. Neither has a value by default, on either path. + * + *

    ALPN (e.g. TLS-ALPN-01 / RFC 8737)

    + * {@link #applicationProtocols(String...)} sets the listener's negotiable protocol list via + * {@link SSLParameters#setApplicationProtocols}, inherited by every accepted socket exactly like + * {@link ClientAuth} — no per-connection code needed. ALPN is resolved during {@code ClientHello} + * processing/{@code ServerHello} production, which always precedes {@code Certificate} production + * — so a custom {@link javax.net.ssl.X509ExtendedKeyManager} deciding which certificate to serve + * can read the client's negotiated protocol via {@code engine.getHandshakeApplicationProtocol()} + * (or {@code ((SSLSocket) socket).getHandshakeApplicationProtocol()}) inside + * {@code chooseEngineServerAlias}/{@code chooseServerAlias} and it is already resolved by then. */ public final class TlsConfig { @@ -43,11 +53,13 @@ public final class TlsConfig { private final SSLContext context; private final boolean hardenDefaults; private final ClientAuth clientAuth; + private final String[] applicationProtocols; - private TlsConfig(SSLContext context, boolean hardenDefaults, ClientAuth clientAuth) { - this.context = context; - this.hardenDefaults = hardenDefaults; - this.clientAuth = clientAuth; + private TlsConfig(SSLContext context, boolean hardenDefaults, ClientAuth clientAuth, String[] applicationProtocols) { + this.context = context; + this.hardenDefaults = hardenDefaults; + this.clientAuth = clientAuth; + this.applicationProtocols = applicationProtocols; } /** @@ -73,20 +85,32 @@ public final class TlsConfig { SSLContext ctx = SSLContext.getInstance("TLS"); ctx.init(managers, null, null); - return new TlsConfig(ctx, true, ClientAuth.NONE); + return new TlsConfig(ctx, true, ClientAuth.NONE, null); } catch (GeneralSecurityException | IOException e) { throw new IllegalArgumentException("Failed to load TLS keystore: " + path, e); } } - /** Escape hatch — see class Javadoc. Flash applies nothing to the socket beyond {@link #clientAuth}. */ + /** + * Escape hatch — see class Javadoc. Flash applies nothing to the socket beyond what you + * explicitly call ({@link #clientAuth}/{@link #applicationProtocols}) on this instance. + */ public static TlsConfig ofContext(SSLContext context) { - return new TlsConfig(context, false, ClientAuth.NONE); + return new TlsConfig(context, false, ClientAuth.NONE, null); } /** Client-certificate requirement. Applies on either construction path — see class Javadoc. */ public TlsConfig clientAuth(ClientAuth mode) { - return new TlsConfig(context, hardenDefaults, mode); + return new TlsConfig(context, hardenDefaults, mode, applicationProtocols); + } + + /** + * ALPN protocols this listener negotiates, in preference order (e.g. + * {@code "acme-tls/1", "http/1.1"}). Applies on either construction path — see class Javadoc + * for how a custom {@code KeyManager} observes the negotiated value. + */ + public TlsConfig applicationProtocols(String... protocols) { + return new TlsConfig(context, hardenDefaults, clientAuth, protocols.clone()); } // ── Consumed by HttpServer at bind time — not meant for direct use ────────── @@ -96,9 +120,10 @@ public final class TlsConfig { } public void applyTo(SSLServerSocket socket) { - if (hardenDefaults) { + if (hardenDefaults || applicationProtocols != null) { SSLParameters params = socket.getSSLParameters(); - params.setProtocols(SECURE_PROTOCOLS); + if (hardenDefaults) params.setProtocols(SECURE_PROTOCOLS); + if (applicationProtocols != null) params.setApplicationProtocols(applicationProtocols); socket.setSSLParameters(params); } if (clientAuth == ClientAuth.REQUIRE) socket.setNeedClientAuth(true); diff --git a/flash/src/main/java/dev/relism/flash/websocket/WebSocketSession.java b/flash/src/main/java/dev/relism/flash/websocket/WebSocketSession.java index 1f10cef..9e47442 100644 --- a/flash/src/main/java/dev/relism/flash/websocket/WebSocketSession.java +++ b/flash/src/main/java/dev/relism/flash/websocket/WebSocketSession.java @@ -2,6 +2,8 @@ package dev.relism.flash.websocket; import dev.relism.flash.models.Request; +import javax.net.ssl.SSLSession; + import java.io.EOFException; import java.io.IOException; import java.io.InputStream; @@ -78,6 +80,17 @@ public final class WebSocketSession { /** The request that upgraded this connection, or {@code null} — see the 4-arg constructor. */ public Request request() { return request; } + /** + * Whether this connection is TLS (WSS). Delegates to the upgrading {@link #request}'s + * {@link Request#isSecure()} rather than tracking the socket a second time — the request + * already carries it. {@code false} if this session has no backing request (e.g. one opened + * in WS *client* mode via the 5-arg constructor with {@code request == null}). + */ + public boolean isSecure() { return request != null && request.isSecure(); } + + /** TLS session for this connection, or {@code null} for plain WS or no backing request. */ + public SSLSession sslSession() { return request != null ? request.sslSession() : null; } + // ── Public send API ──────────────────────────────────────────────────── public void sendText(byte[] utf8, int off, int len) throws IOException { diff --git a/flash/src/test/java/dev/relism/flash/HttpServerTlsTest.java b/flash/src/test/java/dev/relism/flash/HttpServerTlsTest.java index 4af4997..d1c55d3 100644 --- a/flash/src/test/java/dev/relism/flash/HttpServerTlsTest.java +++ b/flash/src/test/java/dev/relism/flash/HttpServerTlsTest.java @@ -10,13 +10,21 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import dev.relism.flash.websocket.WebSocketFrame; +import dev.relism.flash.websocket.WebSocketHandler; +import dev.relism.flash.websocket.WebSocketSession; + import javax.net.ssl.KeyManager; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SNIHostName; import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSession; import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509ExtendedKeyManager; @@ -30,7 +38,9 @@ import java.security.KeyStore; import java.security.Principal; import java.security.PrivateKey; import java.security.cert.X509Certificate; +import java.util.Base64; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.*; @@ -248,7 +258,7 @@ class HttpServerTlsTest { if (calls.getAndIncrement() == 0) throw new RuntimeException("simulated KeyManager failure"); } - @Override public String chooseEngineServerAlias(String keyType, Principal[] issuers, javax.net.ssl.SSLEngine engine) { + @Override public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) { maybeFail(); return delegate.chooseEngineServerAlias(keyType, issuers, engine); } @@ -301,4 +311,261 @@ class HttpServerTlsTest { assertTrue(response.endsWith("pong")); } } + + // ── ALPN visibility for on-demand cert issuance (TLS-ALPN-01 / RFC 8737) ── + + /** Delegates alias selection, but first records what the KeyManager itself observed as the + * negotiated ALPN protocol at the exact point a real ACME-style KeyManager would decide + * whether to serve a challenge certificate instead of the real one. */ + private static final class RecordingKeyManager extends X509ExtendedKeyManager { + private final X509ExtendedKeyManager delegate; + private final AtomicReference observedProtocol = new AtomicReference<>(); + + RecordingKeyManager(X509ExtendedKeyManager delegate) { this.delegate = delegate; } + + @Override public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) { + observedProtocol.set(engine.getHandshakeApplicationProtocol()); + return delegate.chooseEngineServerAlias(keyType, issuers, engine); + } + @Override public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) { + observedProtocol.set(((SSLSocket) socket).getHandshakeApplicationProtocol()); + return delegate.chooseServerAlias(keyType, issuers, socket); + } + @Override public String[] getClientAliases(String keyType, Principal[] issuers) { return delegate.getClientAliases(keyType, issuers); } + @Override public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { return delegate.chooseClientAlias(keyType, issuers, socket); } + @Override public String[] getServerAliases(String keyType, Principal[] issuers) { return delegate.getServerAliases(keyType, issuers); } + @Override public X509Certificate[] getCertificateChain(String alias) { return delegate.getCertificateChain(alias); } + @Override public PrivateKey getPrivateKey(String alias) { return delegate.getPrivateKey(alias); } + } + + private static RecordingKeyManager buildRecordingContext(java.nio.file.Path ks, SSLContext[] outCtx) throws Exception { + KeyStore store = load(ks, "changeit"); + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(store, "changeit".toCharArray()); + + RecordingKeyManager recorder = null; + KeyManager[] managers = kmf.getKeyManagers(); + for (int i = 0; i < managers.length; i++) { + if (managers[i] instanceof X509ExtendedKeyManager x509) { + recorder = new RecordingKeyManager(x509); + managers[i] = recorder; + } + } + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(managers, null, null); + outCtx[0] = ctx; + return recorder; + } + + @Test + void alpn_negotiatedProtocolIsVisibleToKeyManagerBeforeCertificateIsChosen(@TempDir java.nio.file.Path dir) throws Exception { + var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost")); + + SSLContext[] boxedCtx = new SSLContext[1]; + RecordingKeyManager recorder = buildRecordingContext(ks, boxedCtx); + + int port = freePort(); + app = FlashApp.create(FlashConfiguration.builder() + .port(port).host("127.0.0.1") + .tls(TlsConfig.ofContext(boxedCtx[0]).applicationProtocols("acme-tls/1", "http/1.1")) + .build()); + app.get("/ping", (req, res) -> "pong"); + app.start(); + + // Each connection below uses its own fresh client SSLContext, deliberately — reusing one + // SSLContext across connections lets JSSE resume the second handshake's TLS session, + // which skips the Certificate message (and therefore the KeyManager call) entirely. A + // fresh SSLContext has nothing to resume, forcing a full handshake every time, which is + // what this test needs to observe the KeyManager on every connection. + + // A client offering only the ACME challenge protocol: the KeyManager must see it + // *before* choosing which certificate to serve — this is the hook the challenge-cert + // decision hangs off. + try (SSLSocket socket = (SSLSocket) TestKeystores.trustAllClientContext() + .getSocketFactory().createSocket("127.0.0.1", port)) { + SSLParameters params = socket.getSSLParameters(); + params.setApplicationProtocols(new String[] { "acme-tls/1" }); + socket.setSSLParameters(params); + socket.setSoTimeout(SOCKET_TIMEOUT_MS); + socket.startHandshake(); + assertEquals("acme-tls/1", recorder.observedProtocol.get()); + assertEquals("acme-tls/1", socket.getApplicationProtocol()); + } + + // Zero regression: a normal client (http/1.1) behaves exactly as before ALPN existed — + // same route, same response, and the KeyManager sees the normal protocol, not the + // challenge one. + try (SSLSocket socket = (SSLSocket) TestKeystores.trustAllClientContext() + .getSocketFactory().createSocket("127.0.0.1", port)) { + SSLParameters params = socket.getSSLParameters(); + params.setApplicationProtocols(new String[] { "http/1.1" }); + socket.setSSLParameters(params); + String response = httpGet(socket, "/ping"); + assertTrue(response.startsWith("HTTP/1.1 200 OK")); + assertTrue(response.endsWith("pong")); + assertEquals("http/1.1", recorder.observedProtocol.get()); + } + + // Zero regression: a client sending no ALPN at all — today's default — is unaffected. + try (SSLSocket socket = (SSLSocket) TestKeystores.trustAllClientContext() + .getSocketFactory().createSocket("127.0.0.1", port)) { + String response = httpGet(socket, "/ping"); + assertTrue(response.startsWith("HTTP/1.1 200 OK")); + assertTrue(response.endsWith("pong")); + // "" — not null — is JSSE's sentinel for "peer sent no ALPN extension at all". + assertEquals("", recorder.observedProtocol.get()); + } + } + + // ── Request.isSecure() / sslSession() ───────────────────────────────────── + + @Test + void request_isSecureAndSessionAvailableOverTls(@TempDir java.nio.file.Path dir) throws Exception { + var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost")); + int port = freePort(); + app = FlashApp.create(FlashConfiguration.builder() + .port(port).host("127.0.0.1") + .tls(TlsConfig.keystore(ks, "changeit")) + .build()); + app.get("/secure-info", (req, res) -> { + SSLSession session = req.sslSession(); + return req.isSecure() + ":" + (session != null) + ":" + (session != null ? session.getCipherSuite() : ""); + }); + app.start(); + + try (SSLSocket socket = (SSLSocket) TestKeystores.trustAllClientContext() + .getSocketFactory().createSocket("127.0.0.1", port)) { + String response = httpGet(socket, "/secure-info"); + assertTrue(response.startsWith("HTTP/1.1 200 OK")); + assertTrue(response.contains("true:true:TLS_"), + "expected isSecure=true, non-null session, real cipher suite; got: " + response); + } + } + + @Test + void request_peerCertificateVisibleWhenClientPresentsOne(@TempDir java.nio.file.Path dir) throws Exception { + var serverKs = TestKeystores.build(dir, "server.p12", "changeit", TestKeystores.Entry.of("srv", "localhost")); + var clientKs = TestKeystores.build(dir, "client.p12", "changeit", TestKeystores.Entry.of("cli", "test-client")); + + KeyStore serverIdentity = load(serverKs, "changeit"); + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(serverIdentity, "changeit".toCharArray()); + KeyStore clientTrust = load(clientKs, "changeit"); // client's own cert as its trust anchor, as elsewhere in this file + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(clientTrust); + SSLContext serverCtx = SSLContext.getInstance("TLS"); + serverCtx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null); + + int port = freePort(); + app = FlashApp.create(FlashConfiguration.builder() + .port(port).host("127.0.0.1") + // OPTIONAL, not REQUIRE: proves getPeerCertificates() works without also + // re-testing the REQUIRE-rejection path already covered elsewhere in this file. + .tls(TlsConfig.ofContext(serverCtx).clientAuth(ClientAuth.OPTIONAL)) + .build()); + app.get("/secure-info", (req, res) -> { + try { + X509Certificate peer = (X509Certificate) req.sslSession().getPeerCertificates()[0]; + return "peer:" + peer.getSubjectX500Principal().getName(); + } catch (SSLPeerUnverifiedException e) { + return "no-peer-cert"; + } + }); + app.start(); + + KeyStore clientIdentity = load(clientKs, "changeit"); + KeyManagerFactory clientKmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + clientKmf.init(clientIdentity, "changeit".toCharArray()); + SSLContext clientCtx = SSLContext.getInstance("TLS"); + clientCtx.init(clientKmf.getKeyManagers(), new TrustManager[] { trustAll() }, null); + + try (SSLSocket socket = (SSLSocket) clientCtx.getSocketFactory().createSocket("127.0.0.1", port)) { + String response = httpGet(socket, "/secure-info"); + assertTrue(response.startsWith("HTTP/1.1 200 OK")); + assertTrue(response.contains("peer:CN=test-client"), "response was: " + response); + } + } + + @Test + void request_isNotSecureAndSessionIsNullOnPlainListener() throws Exception { + int port = freePort(); + app = FlashApp.create(FlashConfiguration.builder().port(port).host("127.0.0.1").build()); + app.get("/secure-info", (req, res) -> req.isSecure() + ":" + (req.sslSession() == null)); + app.start(); + + try (Socket socket = new Socket("127.0.0.1", port)) { + socket.setSoTimeout(SOCKET_TIMEOUT_MS); + socket.getOutputStream().write( + "GET /secure-info HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" + .getBytes(StandardCharsets.UTF_8)); + String response = new String(socket.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(response.startsWith("HTTP/1.1 200 OK")); + assertTrue(response.endsWith("false:true"), "expected isSecure=false, session=null; got: " + response); + } + } + + // ── WebSocketSession.isSecure() / sslSession() — same info, WSS path ────── + + private static String wsHandshakeKey() { + return Base64.getEncoder().encodeToString("flash-tls-test-key".getBytes(StandardCharsets.UTF_8)); + } + + private static String readWsHandshakeResponse(InputStream in) throws IOException { + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + int b, prev3 = -1, prev2 = -1, prev1 = -1; + while ((b = in.read()) != -1) { + out.write(b); + if (prev3 == '\r' && prev2 == '\n' && prev1 == '\r' && b == '\n') break; + prev3 = prev2; prev2 = prev1; prev1 = b; + } + return out.toString(StandardCharsets.UTF_8); + } + + @Test + void wss_sessionIsSecureAndExposesSslSession(@TempDir java.nio.file.Path dir) throws Exception { + var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost")); + int port = freePort(); + app = FlashApp.create(FlashConfiguration.builder() + .port(port).host("127.0.0.1") + .tls(TlsConfig.keystore(ks, "changeit")) + .build()); + + AtomicReference observedSecure = new AtomicReference<>(); + AtomicReference observedSession = new AtomicReference<>(); + // onOpen runs on the server's virtual thread, asynchronously with respect to the client + // reading the 101 response bytes below (both happen after the same flush, in either + // order) — the latch is what makes the assertions below wait for onOpen, not the socket read. + java.util.concurrent.CountDownLatch opened = new java.util.concurrent.CountDownLatch(1); + app.ws("/echo", new WebSocketHandler() { + @Override public void onOpen(WebSocketSession session) { + observedSecure.set(session.isSecure()); + observedSession.set(session.sslSession()); + opened.countDown(); + } + @Override public void onMessage(WebSocketSession session, WebSocketFrame frame) {} + }); + app.start(); + + try (SSLSocket socket = (SSLSocket) TestKeystores.trustAllClientContext() + .getSocketFactory().createSocket("127.0.0.1", port)) { + socket.setSoTimeout(SOCKET_TIMEOUT_MS); + socket.startHandshake(); + OutputStream out = socket.getOutputStream(); + out.write(("GET /echo HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Key: " + wsHandshakeKey() + "\r\n" + + "Sec-WebSocket-Version: 13\r\n\r\n").getBytes(StandardCharsets.UTF_8)); + out.flush(); + + String headers = readWsHandshakeResponse(socket.getInputStream()); + assertTrue(headers.startsWith("HTTP/1.1 101 Switching Protocols"), "handshake response: " + headers); + assertTrue(opened.await(SOCKET_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS), + "onOpen was not called within timeout"); + } + + assertEquals(Boolean.TRUE, observedSecure.get()); + assertNotNull(observedSession.get()); + } } -- 2.54.0