Merge pull request 'feat(core): ALPN configuration and TLS visibility on Request/WebSocketSession' (#4) from feature/core/tls-alpn into master
Publish Snapshot / Deploy Snapshot (push) Failing after 4m48s
CI / Build & Test (push) Failing after 5m4s

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-08-09 23:38:27 +00:00
7 changed files with 468 additions and 27 deletions
+89
View File
@@ -163,8 +163,96 @@ app.onException((ex, req, res) -> {
|---|---|---| |---|---|---|
| `port` | — | TCP port to bind | | `port` | — | TCP port to bind |
| `host` | `"0.0.0.0"` | Bind address | | `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) | | `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 ## 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. - **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. - **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. - **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 ## Build & test
@@ -19,6 +19,7 @@ import dev.relism.fpr.core.ByteView;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import javax.net.ssl.SSLServerSocket; import javax.net.ssl.SSLServerSocket;
import javax.net.ssl.SSLSocket;
import java.io.*; import java.io.*;
import java.net.InetSocketAddress; import java.net.InetSocketAddress;
@@ -296,7 +297,8 @@ class HttpServer implements ServerHandle {
RequestParser parser = new RequestParser( RequestParser parser = new RequestParser(
configuration.getMaxHeaderBufferSize(), configuration.getMaxHeaderBufferSize(),
(InetSocketAddress) socket.getRemoteSocketAddress()); (InetSocketAddress) socket.getRemoteSocketAddress(),
socket instanceof SSLSocket sslSocket ? sslSocket : null);
while (!stopped) { while (!stopped) {
Request request = parser.parse(in); Request request = parser.parse(in);
@@ -8,6 +8,8 @@ import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import javax.net.ssl.SSLSocket;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.net.InetSocketAddress; import java.net.InetSocketAddress;
@@ -40,6 +42,7 @@ public class RequestParser {
private final int maxHeaderBufferSize; private final int maxHeaderBufferSize;
private final InetSocketAddress remoteAddress; private final InetSocketAddress remoteAddress;
private final SSLSocket sslSocket;
private final HeaderMap headerMap = new HeaderMap(); private final HeaderMap headerMap = new HeaderMap();
private byte[] buffer; private byte[] buffer;
@@ -48,11 +51,17 @@ public class RequestParser {
private int bufBase = 0; private int bufBase = 0;
private int bufLen = 0; private int bufLen = 0;
public RequestParser() { this(64 * 1024, null); } public RequestParser() { this(64 * 1024, null, null); }
public RequestParser(int maxHeaderBufferSize) { this(maxHeaderBufferSize, null); } public RequestParser(int maxHeaderBufferSize) { this(maxHeaderBufferSize, null, null); }
public RequestParser(int maxHeaderBufferSize, InetSocketAddress remoteAddress) { 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.maxHeaderBufferSize = maxHeaderBufferSize;
this.remoteAddress = remoteAddress; this.remoteAddress = remoteAddress;
this.sslSocket = sslSocket;
this.buffer = new byte[Math.min(INITIAL_BUFFER_SIZE, maxHeaderBufferSize)]; this.buffer = new byte[Math.min(INITIAL_BUFFER_SIZE, maxHeaderBufferSize)];
} }
@@ -177,9 +186,9 @@ public class RequestParser {
if (isChunked) { if (isChunked) {
return Request.forParsed(requestLine, return Request.forParsed(requestLine,
new ChunkedInputStream(in, buffer, bodyStart, preBufLen), 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) ── // ── Buffer scanning utilities (hot path — keep branch-free where possible) ──
@@ -9,6 +9,9 @@ import lombok.ToString;
import lombok.Value; import lombok.Value;
import lombok.experimental.NonFinal; import lombok.experimental.NonFinal;
import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocket;
import java.io.InputStream; import java.io.InputStream;
import java.net.InetSocketAddress; import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -60,12 +63,30 @@ public class Request {
@ToString.Exclude @ToString.Exclude
InetSocketAddress remoteAddress; 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.
*
* <p>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.requestLine = requestLine;
this.body = body; this.body = body;
this.pathParams = null; this.pathParams = null;
this.queryParams = null; this.queryParams = null;
this.remoteAddress = remoteAddress; this.remoteAddress = remoteAddress;
this.sslSocket = sslSocket;
} }
/** /**
@@ -75,19 +96,19 @@ public class Request {
*/ */
void setPathParams(PathParams p) { this.pathParams = p; } 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) { 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, public static Request forParsed(RequestLine requestLine, InputStream stream,
long contentLength, byte[] headerBuf, long contentLength, byte[] headerBuf,
int bodyStart, int preBufLen, int bodyStart, int preBufLen,
InetSocketAddress remoteAddress) { InetSocketAddress remoteAddress, SSLSocket sslSocket) {
RequestBody rb = contentLength > 0 ? new RequestBody(stream, contentLength, headerBuf, bodyStart, preBufLen) RequestBody rb = contentLength > 0 ? new RequestBody(stream, contentLength, headerBuf, bodyStart, preBufLen)
: contentLength == 0 ? RequestBody.empty() : contentLength == 0 ? RequestBody.empty()
: /* chunked */ new RequestBody(stream, -1L, null, 0, 0); : /* chunked */ new RequestBody(stream, -1L, null, 0, 0);
return new Request(requestLine, rb, remoteAddress); return new Request(requestLine, rb, remoteAddress, sslSocket);
} }
// ── Request line ────────────────────────────────────────────────────────── // ── Request line ──────────────────────────────────────────────────────────
@@ -170,6 +191,21 @@ public class Request {
*/ */
public InetSocketAddress remoteAddress() { return remoteAddress; } 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 ────────────────────────────────────────────────────────────────── // ── Body ──────────────────────────────────────────────────────────────────
/** /**
@@ -27,14 +27,24 @@ import java.security.KeyStore;
* JDK's own curated default, which each JDK security release keeps current — Flash does * JDK's own curated default, which each JDK security release keeps current — Flash does
* not maintain its own suite allow-list.</li> * not maintain its own suite allow-list.</li>
* <li>{@link #ofContext(SSLContext)} — escape hatch. The given {@link SSLContext} is used * <li>{@link #ofContext(SSLContext)} — escape hatch. The given {@link SSLContext} is used
* exactly as built: Flash never calls {@code setSSLParameters} on this path, so protocols, * exactly as built: Flash never calls {@code setSSLParameters} on this path unless you
* cipher suites, and ALPN (e.g. {@code acme-tls/1} for TLS-ALPN-01) you configured on it * explicitly call {@link #applicationProtocols} or {@link #clientAuth} yourself, so
* are 100% authoritative.</li> * anything else you configured on it is 100% authoritative.</li>
* </ul> * </ul>
* *
* <p>{@link #clientAuth(ClientAuth)} applies on either path — it is an explicit instruction * <p>{@link #clientAuth(ClientAuth)} and {@link #applicationProtocols(String...)} apply on
* through this API, not a Flash-chosen default, so it is only ever applied when called. The * either path — they are explicit instructions through this API, not Flash-chosen defaults, so
* {@link ClientAuth#NONE} default makes no client-auth call at all, on either path. * each is only ever applied when called. Neither has a value by default, on either path.
*
* <h3>ALPN (e.g. TLS-ALPN-01 / RFC 8737)</h3>
* {@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 { public final class TlsConfig {
@@ -43,11 +53,13 @@ public final class TlsConfig {
private final SSLContext context; private final SSLContext context;
private final boolean hardenDefaults; private final boolean hardenDefaults;
private final ClientAuth clientAuth; private final ClientAuth clientAuth;
private final String[] applicationProtocols;
private TlsConfig(SSLContext context, boolean hardenDefaults, ClientAuth clientAuth) { private TlsConfig(SSLContext context, boolean hardenDefaults, ClientAuth clientAuth, String[] applicationProtocols) {
this.context = context; this.context = context;
this.hardenDefaults = hardenDefaults; this.hardenDefaults = hardenDefaults;
this.clientAuth = clientAuth; this.clientAuth = clientAuth;
this.applicationProtocols = applicationProtocols;
} }
/** /**
@@ -73,20 +85,32 @@ public final class TlsConfig {
SSLContext ctx = SSLContext.getInstance("TLS"); SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(managers, null, null); ctx.init(managers, null, null);
return new TlsConfig(ctx, true, ClientAuth.NONE); return new TlsConfig(ctx, true, ClientAuth.NONE, null);
} catch (GeneralSecurityException | IOException e) { } catch (GeneralSecurityException | IOException e) {
throw new IllegalArgumentException("Failed to load TLS keystore: " + path, 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) { 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. */ /** Client-certificate requirement. Applies on either construction path — see class Javadoc. */
public TlsConfig clientAuth(ClientAuth mode) { 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 ────────── // ── Consumed by HttpServer at bind time — not meant for direct use ──────────
@@ -96,9 +120,10 @@ public final class TlsConfig {
} }
public void applyTo(SSLServerSocket socket) { public void applyTo(SSLServerSocket socket) {
if (hardenDefaults) { if (hardenDefaults || applicationProtocols != null) {
SSLParameters params = socket.getSSLParameters(); SSLParameters params = socket.getSSLParameters();
params.setProtocols(SECURE_PROTOCOLS); if (hardenDefaults) params.setProtocols(SECURE_PROTOCOLS);
if (applicationProtocols != null) params.setApplicationProtocols(applicationProtocols);
socket.setSSLParameters(params); socket.setSSLParameters(params);
} }
if (clientAuth == ClientAuth.REQUIRE) socket.setNeedClientAuth(true); if (clientAuth == ClientAuth.REQUIRE) socket.setNeedClientAuth(true);
@@ -2,6 +2,8 @@ package dev.relism.flash.websocket;
import dev.relism.flash.models.Request; import dev.relism.flash.models.Request;
import javax.net.ssl.SSLSession;
import java.io.EOFException; import java.io.EOFException;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; 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. */ /** The request that upgraded this connection, or {@code null} — see the 4-arg constructor. */
public Request request() { return request; } 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 send API ────────────────────────────────────────────────────
public void sendText(byte[] utf8, int off, int len) throws IOException { public void sendText(byte[] utf8, int off, int len) throws IOException {
@@ -10,13 +10,21 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; 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.KeyManager;
import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SNIHostName; import javax.net.ssl.SNIHostName;
import javax.net.ssl.SSLContext; import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLEngine;
import javax.net.ssl.SSLParameters; import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLPeerUnverifiedException;
import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509ExtendedKeyManager; import javax.net.ssl.X509ExtendedKeyManager;
@@ -30,7 +38,9 @@ import java.security.KeyStore;
import java.security.Principal; import java.security.Principal;
import java.security.PrivateKey; import java.security.PrivateKey;
import java.security.cert.X509Certificate; import java.security.cert.X509Certificate;
import java.util.Base64;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
@@ -248,7 +258,7 @@ class HttpServerTlsTest {
if (calls.getAndIncrement() == 0) throw new RuntimeException("simulated KeyManager failure"); 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(); maybeFail();
return delegate.chooseEngineServerAlias(keyType, issuers, engine); return delegate.chooseEngineServerAlias(keyType, issuers, engine);
} }
@@ -301,4 +311,261 @@ class HttpServerTlsTest {
assertTrue(response.endsWith("pong")); 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<String> 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<Boolean> observedSecure = new AtomicReference<>();
AtomicReference<SSLSession> 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());
}
} }