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());
+ }
}