Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
391ae6778e | ||
|
|
1b48d14b4f |
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) ──
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <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.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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.</li>
|
||||
* <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,
|
||||
* cipher suites, and ALPN (e.g. {@code acme-tls/1} for TLS-ALPN-01) you configured on it
|
||||
* are 100% authoritative.</li>
|
||||
* 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.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>{@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.
|
||||
* <p>{@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.
|
||||
*
|
||||
* <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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user