feat(core): add TLS/mTLS support with multi-listener and SNI
Flash can now serve HTTPS and WSS, on one or many listeners per app: - FlashConfiguration gains an optional `tls` field for the single default listener, and a `listeners` list for apps that bind multiple ports (each independently plain or TLS). - New dev.relism.flash.tls package: TlsConfig.keystore(path, password) builds an SSLContext from a PKCS12/JKS keystore, with SNI-based certificate selection for free when the keystore holds more than one alias (matched by SAN/CN, pure JDK APIs). TlsConfig.ofContext(sslContext) is a full escape hatch — Flash never calls setSSLParameters on that path, so caller-set protocols/cipher suites/ALPN survive untouched. TlsConfig.clientAuth(...) adds optional/required mTLS on either path. - HttpServer moves from a single ServerSocket to a list of bound listeners; the per-request hot path (RequestParser, routing, response writing) is untouched — TLS only changes which bytes come out of accept(), so WSS needs no separate code path from WS. - process()'s catch is widened to log non-IOException failures (e.g. a misbehaving custom KeyManager/TrustManager on the ofContext path) instead of swallowing them silently; the failure was already isolated to the one connection via the existing try-with-resources/executor-submission boundary — this only fixes the missing log line. - Fixes a pre-existing gap where FlashConfiguration#host was accepted but never used to bind (listeners always bound to the wildcard address). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
524bdeb28b
commit
9f6808e90c
@@ -10,6 +10,7 @@ import dev.relism.flash.models.RequestHandler;
|
|||||||
import dev.relism.flash.models.Response;
|
import dev.relism.flash.models.Response;
|
||||||
import dev.relism.flash.routing.AbstractRouter;
|
import dev.relism.flash.routing.AbstractRouter;
|
||||||
import dev.relism.flash.routing.AbstractWsRouter;
|
import dev.relism.flash.routing.AbstractWsRouter;
|
||||||
|
import dev.relism.flash.tls.TlsConfig;
|
||||||
import dev.relism.flash.websocket.WebSocketFrame;
|
import dev.relism.flash.websocket.WebSocketFrame;
|
||||||
import dev.relism.flash.websocket.WebSocketHandler;
|
import dev.relism.flash.websocket.WebSocketHandler;
|
||||||
import dev.relism.flash.websocket.WebSocketSession;
|
import dev.relism.flash.websocket.WebSocketSession;
|
||||||
@@ -17,6 +18,8 @@ import dev.relism.fpr.core.ByteView;
|
|||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import javax.net.ssl.SSLServerSocket;
|
||||||
|
|
||||||
import java.io.*;
|
import java.io.*;
|
||||||
import java.net.InetSocketAddress;
|
import java.net.InetSocketAddress;
|
||||||
import java.net.ServerSocket;
|
import java.net.ServerSocket;
|
||||||
@@ -24,15 +27,23 @@ import java.net.Socket;
|
|||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.*;
|
import java.util.concurrent.*;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure I/O transport layer. Owns the {@link ServerSocket}, the virtual-thread
|
* Pure I/O transport layer. Owns one {@link ServerSocket} per configured listener (plain or
|
||||||
* executor, and the keep-alive accept loop. Routing is delegated to HTTP and WS routers.
|
* TLS), the virtual-thread executor, and the keep-alive accept loop. Routing is delegated to
|
||||||
|
* HTTP and WS routers — identically, regardless of which listener accepted the connection.
|
||||||
|
*
|
||||||
|
* <p>TLS is a transport-level concern only: once a {@link BoundListener} is bound, an accepted
|
||||||
|
* {@link Socket} is either plain or an {@code SSLSocket} indistinguishably from here on —
|
||||||
|
* {@link #process} never branches on it. This is also why WSS needs no separate code path from
|
||||||
|
* WS: the WebSocket upgrade happens over whatever transport {@link #process} was handed.
|
||||||
*
|
*
|
||||||
* <h3>Allocation model (unchanged)</h3>
|
* <h3>Allocation model (unchanged)</h3>
|
||||||
* <ul>
|
* <ul>
|
||||||
@@ -77,15 +88,18 @@ class HttpServer implements ServerHandle {
|
|||||||
// ── Instance fields ───────────────────────────────────────────────────────
|
// ── Instance fields ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
private final FlashConfiguration configuration;
|
private final FlashConfiguration configuration;
|
||||||
private final ServerSocket serverSocket;
|
private final List<BoundListener> boundListeners;
|
||||||
private final AbstractRouter router;
|
private final AbstractRouter router;
|
||||||
private final AbstractWsRouter wsRouter;
|
private final AbstractWsRouter wsRouter;
|
||||||
private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
|
private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
|
||||||
private final Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
|
private final Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
|
||||||
private volatile boolean stopped = false;
|
private volatile boolean stopped = false;
|
||||||
|
|
||||||
/** Latch that reaches 0 when all accept threads have exited. */
|
/** Latch that reaches 0 when all accept threads, across all listeners, have exited. */
|
||||||
private final CountDownLatch acceptLatch = new CountDownLatch(ACCEPT_THREADS);
|
private final CountDownLatch acceptLatch;
|
||||||
|
|
||||||
|
/** One bound listener socket (plain or TLS) plus whether it is TLS, for logging only. */
|
||||||
|
private record BoundListener(ServerSocket socket, boolean secure) {}
|
||||||
|
|
||||||
// ── Static byte constants (written once, read-only on hot path) ──────────
|
// ── Static byte constants (written once, read-only on hot path) ──────────
|
||||||
|
|
||||||
@@ -132,27 +146,60 @@ class HttpServer implements ServerHandle {
|
|||||||
this.router = router;
|
this.router = router;
|
||||||
this.wsRouter = wsRouter;
|
this.wsRouter = wsRouter;
|
||||||
|
|
||||||
// Explicit bind with raised backlog.
|
List<FlashConfiguration.Listener> specs = configuration.getListeners().isEmpty()
|
||||||
// setReuseAddress(true) must be called BEFORE bind().
|
? List.of(new FlashConfiguration.Listener(
|
||||||
this.serverSocket = new ServerSocket();
|
configuration.getPort(), configuration.getHost(), configuration.getTls()))
|
||||||
this.serverSocket.setReuseAddress(true);
|
: configuration.getListeners();
|
||||||
this.serverSocket.setReceiveBufferSize(SOCKET_BUF_SIZE);
|
|
||||||
this.serverSocket.bind(new InetSocketAddress(configuration.getPort()), ACCEPT_BACKLOG);
|
|
||||||
|
|
||||||
log.info("HttpServer bound on port {} (backlog={}, acceptThreads={})",
|
List<BoundListener> bound = new ArrayList<>(specs.size());
|
||||||
configuration.getPort(), ACCEPT_BACKLOG, ACCEPT_THREADS);
|
for (FlashConfiguration.Listener spec : specs) bound.add(bind(spec));
|
||||||
|
this.boundListeners = List.copyOf(bound);
|
||||||
|
this.acceptLatch = new CountDownLatch(ACCEPT_THREADS * boundListeners.size());
|
||||||
|
|
||||||
|
for (BoundListener bl : boundListeners) {
|
||||||
|
log.info("HttpServer bound on {}:{} (tls={}, backlog={}, acceptThreads={})",
|
||||||
|
bl.socket().getInetAddress(), bl.socket().getLocalPort(), bl.secure(),
|
||||||
|
ACCEPT_BACKLOG, ACCEPT_THREADS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds one listener. A TLS listener gets its {@link ServerSocket} from
|
||||||
|
* {@link TlsConfig#serverSocketFactory()} instead of {@code new ServerSocket()}, and its
|
||||||
|
* protocol/client-auth parameters from {@link TlsConfig#applyTo} — reuse-address, receive
|
||||||
|
* buffer size, backlog and the bind call itself are identical either way. TLS only changes
|
||||||
|
* which bytes come out of {@code accept()}; it never changes how the accept loop, or
|
||||||
|
* anything downstream of it, treats them.
|
||||||
|
*/
|
||||||
|
private static BoundListener bind(FlashConfiguration.Listener spec) throws IOException {
|
||||||
|
TlsConfig tls = spec.tls();
|
||||||
|
|
||||||
|
ServerSocket socket = tls != null ? tls.serverSocketFactory().createServerSocket() : new ServerSocket();
|
||||||
|
// setReuseAddress(true) must be called BEFORE bind().
|
||||||
|
socket.setReuseAddress(true);
|
||||||
|
socket.setReceiveBufferSize(SOCKET_BUF_SIZE);
|
||||||
|
if (tls != null) tls.applyTo((SSLServerSocket) socket);
|
||||||
|
|
||||||
|
InetSocketAddress addr = spec.host() != null
|
||||||
|
? new InetSocketAddress(spec.host(), spec.port())
|
||||||
|
: new InetSocketAddress(spec.port());
|
||||||
|
socket.bind(addr, ACCEPT_BACKLOG);
|
||||||
|
|
||||||
|
return new BoundListener(socket, tls != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void start() {
|
public void start() {
|
||||||
|
for (int li = 0; li < boundListeners.size(); li++) {
|
||||||
|
BoundListener listener = boundListeners.get(li);
|
||||||
for (int i = 0; i < ACCEPT_THREADS; i++) {
|
for (int i = 0; i < ACCEPT_THREADS; i++) {
|
||||||
final int idx = i;
|
|
||||||
Thread.ofPlatform()
|
Thread.ofPlatform()
|
||||||
.name("flash-accept-" + idx)
|
.name("flash-accept-" + li + "-" + i)
|
||||||
.daemon(false)
|
.daemon(false)
|
||||||
.start(this::acceptLoop);
|
.start(() -> acceptLoop(listener));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,14 +212,15 @@ class HttpServer implements ServerHandle {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Single accept loop body — runs on each of the {@code ACCEPT_THREADS}
|
* Single accept loop body — runs on each of the {@code ACCEPT_THREADS}
|
||||||
* platform threads. All threads block on the same {@link ServerSocket};
|
* platform threads bound to one {@code listener}. All threads for that listener block on
|
||||||
* the JVM ensures only one wakes per incoming connection (no thundering herd).
|
* the same {@link ServerSocket}; the JVM ensures only one wakes per incoming connection
|
||||||
|
* (no thundering herd). Other listeners' accept threads are entirely independent.
|
||||||
*/
|
*/
|
||||||
private void acceptLoop() {
|
private void acceptLoop(BoundListener listener) {
|
||||||
try {
|
try {
|
||||||
while (!stopped) {
|
while (!stopped) {
|
||||||
try {
|
try {
|
||||||
process(serverSocket.accept());
|
process(listener.socket().accept());
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
if (!stopped) log.error("Accept error", e);
|
if (!stopped) log.error("Accept error", e);
|
||||||
}
|
}
|
||||||
@@ -186,7 +234,9 @@ class HttpServer implements ServerHandle {
|
|||||||
public CompletableFuture<Void> stop() {
|
public CompletableFuture<Void> stop() {
|
||||||
return CompletableFuture.runAsync(() -> {
|
return CompletableFuture.runAsync(() -> {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
try { serverSocket.close(); } catch (IOException e) { log.error("Error closing server socket", e); }
|
for (BoundListener bl : boundListeners) {
|
||||||
|
try { bl.socket().close(); } catch (IOException e) { log.error("Error closing server socket", e); }
|
||||||
|
}
|
||||||
activeSockets.forEach(s -> { try { s.close(); } catch (IOException ignored) {} });
|
activeSockets.forEach(s -> { try { s.close(); } catch (IOException ignored) {} });
|
||||||
executorService.shutdown();
|
executorService.shutdown();
|
||||||
try {
|
try {
|
||||||
@@ -281,6 +331,14 @@ class HttpServer implements ServerHandle {
|
|||||||
else
|
else
|
||||||
log.error("I/O error handling request", e);
|
log.error("I/O error handling request", e);
|
||||||
}
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Anything not an IOException here means a collaborator misbehaved on the TLS
|
||||||
|
// handshake path — most likely a custom TlsConfig#ofContext KeyManager/
|
||||||
|
// TrustManager throwing (e.g. a failed DB lookup or on-demand cert issuance).
|
||||||
|
// That failure is isolated to this one virtual thread/connection: the
|
||||||
|
// try-with-resources above still closes the socket, the finally below still
|
||||||
|
// runs, and the accept loop (a different thread entirely) never sees this.
|
||||||
|
if (!stopped) log.error("Unexpected error handling connection", e);
|
||||||
} finally {
|
} finally {
|
||||||
activeSockets.remove(socket);
|
activeSockets.remove(socket);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,14 +65,12 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
|||||||
private final List<Middleware> globalMiddlewares = new ArrayList<>();
|
private final List<Middleware> globalMiddlewares = new ArrayList<>();
|
||||||
private final List<RouteDefinition> deferredRoutes = new ArrayList<>();
|
private final List<RouteDefinition> deferredRoutes = new ArrayList<>();
|
||||||
private final List<WsRouteDefinition> deferredWsRoutes = new ArrayList<>();
|
private final List<WsRouteDefinition> deferredWsRoutes = new ArrayList<>();
|
||||||
private int port;
|
|
||||||
|
|
||||||
private record WsRouteDefinition(String path, WebSocketEndpoint endpoint) {}
|
private record WsRouteDefinition(String path, WebSocketEndpoint endpoint) {}
|
||||||
|
|
||||||
private FlashApp(FlashConfiguration config) {
|
private FlashApp(FlashConfiguration config) {
|
||||||
try {
|
try {
|
||||||
this.server = ServerHandle.create(config, router, wsRouter);
|
this.server = ServerHandle.create(config, router, wsRouter);
|
||||||
this.port = config.getPort();
|
|
||||||
}
|
}
|
||||||
catch (IOException e) { throw new InitializationException("Failed to bind on port " + config.getPort(), e); }
|
catch (IOException e) { throw new InitializationException("Failed to bind on port " + config.getPort(), e); }
|
||||||
}
|
}
|
||||||
@@ -157,7 +155,7 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
|||||||
public FlashApp start() {
|
public FlashApp start() {
|
||||||
boot();
|
boot();
|
||||||
server.start();
|
server.start();
|
||||||
if (Flash.DEV) log.info("[Flash] Started (dev mode): listening on port " + port);
|
if (Flash.DEV) log.info("[Flash] Started (dev mode) — see HttpServer bind logs above for listener details");
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +165,7 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
|||||||
*/
|
*/
|
||||||
public void startAndBlock() {
|
public void startAndBlock() {
|
||||||
boot();
|
boot();
|
||||||
if (Flash.DEV) log.info("[Flash] Started (dev mode): listening on port " + port);
|
if (Flash.DEV) log.info("[Flash] Started (dev mode) — see HttpServer bind logs above for listener details");
|
||||||
server.startAndBlock();
|
server.startAndBlock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
package dev.relism.flash.extension;
|
package dev.relism.flash.extension;
|
||||||
|
|
||||||
|
import dev.relism.flash.tls.TlsConfig;
|
||||||
|
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
|
import lombok.Singular;
|
||||||
import lombok.Value;
|
import lombok.Value;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for a {@link FlashApp} instance.
|
* Configuration for a {@link FlashApp} instance.
|
||||||
*
|
*
|
||||||
@@ -16,6 +21,18 @@ import lombok.Value;
|
|||||||
* .host("127.0.0.1")
|
* .host("127.0.0.1")
|
||||||
* .maxHeaderBufferSize(128 * 1024)
|
* .maxHeaderBufferSize(128 * 1024)
|
||||||
* .build());
|
* .build());
|
||||||
|
*
|
||||||
|
* // TLS on the single default listener
|
||||||
|
* FlashApp.create(FlashConfiguration.builder()
|
||||||
|
* .port(443)
|
||||||
|
* .tls(TlsConfig.keystore(Path.of("cert.p12"), "changeit"))
|
||||||
|
* .build());
|
||||||
|
*
|
||||||
|
* // Multiple listeners on one app — takes precedence over port/host/tls above
|
||||||
|
* FlashApp.create(FlashConfiguration.builder()
|
||||||
|
* .listener(new FlashConfiguration.Listener(80))
|
||||||
|
* .listener(new FlashConfiguration.Listener(443, TlsConfig.keystore(Path.of("cert.p12"), "changeit")))
|
||||||
|
* .build());
|
||||||
* }</pre>
|
* }</pre>
|
||||||
*/
|
*/
|
||||||
@Value
|
@Value
|
||||||
@@ -25,6 +42,13 @@ public class FlashConfiguration {
|
|||||||
int port;
|
int port;
|
||||||
String host;
|
String host;
|
||||||
|
|
||||||
|
/** TLS for the single default listener ({@link #port}/{@link #host}). Ignored if {@link #listeners} is non-empty. */
|
||||||
|
TlsConfig tls;
|
||||||
|
|
||||||
|
/** One or more listeners for this app. Non-empty list takes precedence over {@link #port}/{@link #host}/{@link #tls}. */
|
||||||
|
@Singular
|
||||||
|
List<Listener> listeners;
|
||||||
|
|
||||||
/** Maximum size of the request header buffer in bytes. Default: 64 KB. */
|
/** Maximum size of the request header buffer in bytes. Default: 64 KB. */
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
int maxHeaderBufferSize = 64 * 1024;
|
int maxHeaderBufferSize = 64 * 1024;
|
||||||
@@ -32,4 +56,10 @@ public class FlashConfiguration {
|
|||||||
/** Per-connection WebSocket read buffer size in bytes. Default: 64 KB. */
|
/** Per-connection WebSocket read buffer size in bytes. Default: 64 KB. */
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
int wsFrameBufferSize = 64 * 1024;
|
int wsFrameBufferSize = 64 * 1024;
|
||||||
|
|
||||||
|
/** One bind target: a TCP port, an optional bind host (default: all interfaces), and optional TLS. */
|
||||||
|
public record Listener(int port, String host, TlsConfig tls) {
|
||||||
|
public Listener(int port) { this(port, null, null); }
|
||||||
|
public Listener(int port, TlsConfig tls) { this(port, null, tls); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package dev.relism.flash.tls;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-certificate requirement for a TLS listener, applied via
|
||||||
|
* {@link TlsConfig#clientAuth(ClientAuth)}.
|
||||||
|
*/
|
||||||
|
public enum ClientAuth {
|
||||||
|
/** No client certificate requested. Default — Flash makes no client-auth call at all. */
|
||||||
|
NONE,
|
||||||
|
/** Client certificate requested; handshake still succeeds if the client presents none. */
|
||||||
|
OPTIONAL,
|
||||||
|
/** Handshake fails unless the client presents a certificate trusted by this listener. */
|
||||||
|
REQUIRE
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package dev.relism.flash.tls;
|
||||||
|
|
||||||
|
import javax.net.ssl.ExtendedSSLSession;
|
||||||
|
import javax.net.ssl.SNIHostName;
|
||||||
|
import javax.net.ssl.SNIServerName;
|
||||||
|
import javax.net.ssl.SSLEngine;
|
||||||
|
import javax.net.ssl.SSLSession;
|
||||||
|
import javax.net.ssl.SSLSocket;
|
||||||
|
import javax.net.ssl.X509ExtendedKeyManager;
|
||||||
|
|
||||||
|
import java.net.Socket;
|
||||||
|
import java.security.KeyStore;
|
||||||
|
import java.security.KeyStoreException;
|
||||||
|
import java.security.Principal;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
import java.security.cert.Certificate;
|
||||||
|
import java.security.cert.CertificateParsingException;
|
||||||
|
import java.security.cert.X509Certificate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a keystore's default {@link X509ExtendedKeyManager} with SNI-based alias selection:
|
||||||
|
* every alias's certificate is inspected for its Subject Alternative Names (Common Name as
|
||||||
|
* fallback) so a keystore holding one entry per domain serves the right certificate per
|
||||||
|
* {@code ClientHello} — no explicit hostname-to-alias mapping needed from the caller.
|
||||||
|
*
|
||||||
|
* <p>{@link #defaultAlias} is simply the keystore's first key entry — same convention as
|
||||||
|
* nginx/HAProxy's {@code default_server}: used when the client sends no SNI, or an SNI name
|
||||||
|
* that matches nothing here.
|
||||||
|
*
|
||||||
|
* <p>Only server-alias selection is overridden; every other {@link X509ExtendedKeyManager}
|
||||||
|
* method (client aliases, certificate chains, private keys) delegates unchanged.
|
||||||
|
*/
|
||||||
|
final class SniKeyManager extends X509ExtendedKeyManager {
|
||||||
|
|
||||||
|
private final X509ExtendedKeyManager delegate;
|
||||||
|
private final Map<String, String> aliasByHostname;
|
||||||
|
private final String defaultAlias;
|
||||||
|
|
||||||
|
SniKeyManager(X509ExtendedKeyManager delegate, KeyStore keyStore) throws KeyStoreException {
|
||||||
|
this.delegate = delegate;
|
||||||
|
|
||||||
|
Map<String, String> byHostname = new HashMap<>();
|
||||||
|
String first = null;
|
||||||
|
for (String alias : Collections.list(keyStore.aliases())) {
|
||||||
|
if (!keyStore.isKeyEntry(alias)) continue;
|
||||||
|
if (first == null) first = alias;
|
||||||
|
Certificate cert = keyStore.getCertificate(alias);
|
||||||
|
if (cert instanceof X509Certificate x509) {
|
||||||
|
for (String host : hostnamesOf(x509)) byHostname.putIfAbsent(host, alias);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.aliasByHostname = byHostname;
|
||||||
|
this.defaultAlias = first;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DNS SANs (preferred) or, failing that, the certificate's CN — all lower-cased for matching. */
|
||||||
|
private static List<String> hostnamesOf(X509Certificate cert) {
|
||||||
|
List<String> names = new ArrayList<>();
|
||||||
|
try {
|
||||||
|
Collection<List<?>> sans = cert.getSubjectAlternativeNames();
|
||||||
|
if (sans != null) {
|
||||||
|
for (List<?> san : sans) {
|
||||||
|
if (san.get(0).equals(2)) // dNSName, see X509Certificate#getSubjectAlternativeNames
|
||||||
|
names.add(san.get(1).toString().toLowerCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (CertificateParsingException ignored) {
|
||||||
|
// Fall through to the CN below.
|
||||||
|
}
|
||||||
|
if (names.isEmpty()) {
|
||||||
|
for (String part : cert.getSubjectX500Principal().getName().split(",")) {
|
||||||
|
part = part.trim();
|
||||||
|
if (part.regionMatches(true, 0, "CN=", 0, 3)) {
|
||||||
|
names.add(part.substring(3).toLowerCase(Locale.ROOT));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) {
|
||||||
|
return resolve(engine.getHandshakeSession());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) {
|
||||||
|
return resolve(socket instanceof SSLSocket ssl ? ssl.getHandshakeSession() : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every TLS implementation in practice sends at most one {@code server_name} entry (RFC 6066
|
||||||
|
* permits a list, but only the {@code host_name} type exists and clients send zero or one of
|
||||||
|
* it), so indexing {@code names.get(0)} directly — rather than a for-each, which would
|
||||||
|
* allocate an {@link java.util.Iterator} per handshake — is both correct and allocation-free
|
||||||
|
* on the JDK's own {@code List.copyOf}-backed {@link SSLSession#getRequestedServerNames()},
|
||||||
|
* deterministically rather than relying on the JIT to prove the iterator never escapes.
|
||||||
|
*/
|
||||||
|
private String resolve(SSLSession session) {
|
||||||
|
if (session instanceof ExtendedSSLSession ext) {
|
||||||
|
List<SNIServerName> names = ext.getRequestedServerNames();
|
||||||
|
if (!names.isEmpty() && names.get(0) instanceof SNIHostName host) {
|
||||||
|
String alias = aliasByHostname.get(host.getAsciiName().toLowerCase(Locale.ROOT));
|
||||||
|
if (alias != null) return alias;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultAlias;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delegated — this class only changes server-alias selection ─────────────
|
||||||
|
|
||||||
|
@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 chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) { return delegate.chooseEngineClientAlias(keyType, issuers, engine); }
|
||||||
|
@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); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package dev.relism.flash.tls;
|
||||||
|
|
||||||
|
import javax.net.ssl.KeyManager;
|
||||||
|
import javax.net.ssl.KeyManagerFactory;
|
||||||
|
import javax.net.ssl.SSLContext;
|
||||||
|
import javax.net.ssl.SSLParameters;
|
||||||
|
import javax.net.ssl.SSLServerSocket;
|
||||||
|
import javax.net.ssl.SSLServerSocketFactory;
|
||||||
|
import javax.net.ssl.X509ExtendedKeyManager;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.security.GeneralSecurityException;
|
||||||
|
import java.security.KeyStore;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declarative TLS configuration for a {@link dev.relism.flash.extension.FlashConfiguration.Listener}.
|
||||||
|
*
|
||||||
|
* <h3>Two ways in</h3>
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #keystore(Path, String)} — Flash builds the {@link SSLContext} from a PKCS12/JKS
|
||||||
|
* keystore. A keystore holding more than one certificate entry gets SNI-based selection
|
||||||
|
* for free (see {@link SniKeyManager}) — no per-hostname config needed. Flash also pins
|
||||||
|
* {@code TLSv1.2}/{@code TLSv1.3} as the enabled protocols; cipher suites are left at the
|
||||||
|
* 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>
|
||||||
|
* </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.
|
||||||
|
*/
|
||||||
|
public final class TlsConfig {
|
||||||
|
|
||||||
|
private static final String[] SECURE_PROTOCOLS = { "TLSv1.3", "TLSv1.2" };
|
||||||
|
|
||||||
|
private final SSLContext context;
|
||||||
|
private final boolean hardenDefaults;
|
||||||
|
private final ClientAuth clientAuth;
|
||||||
|
|
||||||
|
private TlsConfig(SSLContext context, boolean hardenDefaults, ClientAuth clientAuth) {
|
||||||
|
this.context = context;
|
||||||
|
this.hardenDefaults = hardenDefaults;
|
||||||
|
this.clientAuth = clientAuth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds an {@link SSLContext} from a PKCS12/JKS keystore — type is guessed from the file
|
||||||
|
* extension ({@code .jks} means JKS, anything else PKCS12). The private-key password is
|
||||||
|
* assumed equal to the store password, the common case for PKCS12.
|
||||||
|
*/
|
||||||
|
public static TlsConfig keystore(Path path, String password) {
|
||||||
|
try {
|
||||||
|
KeyStore store = KeyStore.getInstance(path.toString().endsWith(".jks") ? "JKS" : "PKCS12");
|
||||||
|
try (InputStream in = Files.newInputStream(path)) {
|
||||||
|
store.load(in, password.toCharArray());
|
||||||
|
}
|
||||||
|
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
|
||||||
|
kmf.init(store, password.toCharArray());
|
||||||
|
|
||||||
|
KeyManager[] managers = kmf.getKeyManagers();
|
||||||
|
for (int i = 0; i < managers.length; i++) {
|
||||||
|
if (managers[i] instanceof X509ExtendedKeyManager x509) {
|
||||||
|
managers[i] = new SniKeyManager(x509, store);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SSLContext ctx = SSLContext.getInstance("TLS");
|
||||||
|
ctx.init(managers, null, null);
|
||||||
|
return new TlsConfig(ctx, true, ClientAuth.NONE);
|
||||||
|
} 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}. */
|
||||||
|
public static TlsConfig ofContext(SSLContext context) {
|
||||||
|
return new TlsConfig(context, false, ClientAuth.NONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client-certificate requirement. Applies on either construction path — see class Javadoc. */
|
||||||
|
public TlsConfig clientAuth(ClientAuth mode) {
|
||||||
|
return new TlsConfig(context, hardenDefaults, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Consumed by HttpServer at bind time — not meant for direct use ──────────
|
||||||
|
|
||||||
|
public SSLServerSocketFactory serverSocketFactory() {
|
||||||
|
return context.getServerSocketFactory();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void applyTo(SSLServerSocket socket) {
|
||||||
|
if (hardenDefaults) {
|
||||||
|
SSLParameters params = socket.getSSLParameters();
|
||||||
|
params.setProtocols(SECURE_PROTOCOLS);
|
||||||
|
socket.setSSLParameters(params);
|
||||||
|
}
|
||||||
|
if (clientAuth == ClientAuth.REQUIRE) socket.setNeedClientAuth(true);
|
||||||
|
else if (clientAuth == ClientAuth.OPTIONAL) socket.setWantClientAuth(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
package dev.relism.flash;
|
||||||
|
|
||||||
|
import dev.relism.flash.extension.FlashApp;
|
||||||
|
import dev.relism.flash.extension.FlashConfiguration;
|
||||||
|
import dev.relism.flash.tls.ClientAuth;
|
||||||
|
import dev.relism.flash.tls.TestKeystores;
|
||||||
|
import dev.relism.flash.tls.TlsConfig;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import javax.net.ssl.KeyManager;
|
||||||
|
import javax.net.ssl.KeyManagerFactory;
|
||||||
|
import javax.net.ssl.SNIHostName;
|
||||||
|
import javax.net.ssl.SSLContext;
|
||||||
|
import javax.net.ssl.SSLParameters;
|
||||||
|
import javax.net.ssl.SSLSocket;
|
||||||
|
import javax.net.ssl.SSLSocketFactory;
|
||||||
|
import javax.net.ssl.TrustManagerFactory;
|
||||||
|
import javax.net.ssl.X509ExtendedKeyManager;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.Socket;
|
||||||
|
import java.net.ServerSocket;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.KeyStore;
|
||||||
|
import java.security.Principal;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
import java.security.cert.X509Certificate;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end TLS coverage over real sockets — same style as {@link HttpServerTest}, just with
|
||||||
|
* an {@link SSLSocketFactory} client instead of a raw one. Certificates are generated per test
|
||||||
|
* via {@link TestKeystores} (JDK {@code keytool}, no fixture files, no extra crypto dependency).
|
||||||
|
*/
|
||||||
|
class HttpServerTlsTest {
|
||||||
|
|
||||||
|
private static final int SOCKET_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
private FlashApp app;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
if (app != null) app.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int freePort() throws IOException {
|
||||||
|
try (ServerSocket s = new ServerSocket(0)) { return s.getLocalPort(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String httpGet(SSLSocket socket, String path) throws IOException {
|
||||||
|
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
|
||||||
|
socket.startHandshake();
|
||||||
|
OutputStream out = socket.getOutputStream();
|
||||||
|
out.write(("GET " + path + " HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
|
||||||
|
.getBytes(StandardCharsets.UTF_8));
|
||||||
|
out.flush();
|
||||||
|
return new String(socket.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Basic HTTPS ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void httpsRequest_servedOverModernTls(@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("/ping", (req, res) -> "pong");
|
||||||
|
app.start();
|
||||||
|
|
||||||
|
SSLSocketFactory factory = TestKeystores.trustAllClientContext().getSocketFactory();
|
||||||
|
try (SSLSocket socket = (SSLSocket) factory.createSocket("127.0.0.1", port)) {
|
||||||
|
String response = httpGet(socket, "/ping");
|
||||||
|
assertTrue(response.startsWith("HTTP/1.1 200 OK"));
|
||||||
|
assertTrue(response.endsWith("pong"));
|
||||||
|
assertTrue(java.util.List.of("TLSv1.2", "TLSv1.3").contains(socket.getSession().getProtocol()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SNI: one keystore, two domains, two certificates ────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sni_servesCertificateMatchingRequestedHostname(@TempDir java.nio.file.Path dir) throws Exception {
|
||||||
|
var ks = TestKeystores.build(dir, "sni.p12", "changeit",
|
||||||
|
TestKeystores.Entry.of("a", "a.test"),
|
||||||
|
TestKeystores.Entry.of("b", "b.test"));
|
||||||
|
int port = freePort();
|
||||||
|
app = FlashApp.create(FlashConfiguration.builder()
|
||||||
|
.port(port).host("127.0.0.1")
|
||||||
|
.tls(TlsConfig.keystore(ks, "changeit"))
|
||||||
|
.build());
|
||||||
|
app.get("/ping", (req, res) -> "pong");
|
||||||
|
app.start();
|
||||||
|
|
||||||
|
assertEquals("a.test", peerCn(port, "a.test"));
|
||||||
|
assertEquals("b.test", peerCn(port, "b.test"));
|
||||||
|
// No/unknown SNI falls back to the first keystore entry ("a") — same convention as
|
||||||
|
// nginx/HAProxy's default_server.
|
||||||
|
assertEquals("a.test", peerCn(port, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String peerCn(int port, String sniHostname) throws Exception {
|
||||||
|
SSLContext ctx = TestKeystores.trustAllClientContext();
|
||||||
|
try (SSLSocket socket = (SSLSocket) ctx.getSocketFactory().createSocket("127.0.0.1", port)) {
|
||||||
|
if (sniHostname != null) {
|
||||||
|
SSLParameters params = socket.getSSLParameters();
|
||||||
|
params.setServerNames(java.util.List.of(new SNIHostName(sniHostname)));
|
||||||
|
socket.setSSLParameters(params);
|
||||||
|
}
|
||||||
|
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
|
||||||
|
socket.startHandshake();
|
||||||
|
X509Certificate cert = (X509Certificate) socket.getSession().getPeerCertificates()[0];
|
||||||
|
String dn = cert.getSubjectX500Principal().getName();
|
||||||
|
for (String part : dn.split(",")) {
|
||||||
|
part = part.trim();
|
||||||
|
if (part.regionMatches(true, 0, "CN=", 0, 3)) return part.substring(3);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mTLS ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mTls_requireRejectsClientWithNoCertificate(@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").clientAuth(ClientAuth.REQUIRE))
|
||||||
|
.build());
|
||||||
|
app.get("/ping", (req, res) -> "pong");
|
||||||
|
app.start();
|
||||||
|
|
||||||
|
SSLSocketFactory factory = TestKeystores.trustAllClientContext().getSocketFactory();
|
||||||
|
try (SSLSocket socket = (SSLSocket) factory.createSocket("127.0.0.1", port)) {
|
||||||
|
// TLS 1.3 validates the (here: empty) client certificate chain only after the
|
||||||
|
// client's Finished message — startHandshake() alone can return cleanly from the
|
||||||
|
// client's point of view. The server's fatal alert only surfaces on the next I/O,
|
||||||
|
// so the round trip (not the handshake call itself) is what must throw.
|
||||||
|
assertThrows(IOException.class, () -> httpGet(socket, "/ping"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mTls_requireAcceptsClientWithTrustedCertificate(@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"));
|
||||||
|
|
||||||
|
// Escape hatch: mutual trust needs a trust store on both sides, which the keystore()
|
||||||
|
// convenience path deliberately doesn't expose (see TlsConfig javadoc) — this is
|
||||||
|
// exactly the case ofContext() exists for.
|
||||||
|
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 acts as its trust anchor here
|
||||||
|
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")
|
||||||
|
.tls(TlsConfig.ofContext(serverCtx).clientAuth(ClientAuth.REQUIRE))
|
||||||
|
.build());
|
||||||
|
app.get("/ping", (req, res) -> "pong");
|
||||||
|
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 javax.net.ssl.TrustManager[] { trustAll() }, null);
|
||||||
|
|
||||||
|
try (SSLSocket socket = (SSLSocket) clientCtx.getSocketFactory().createSocket("127.0.0.1", port)) {
|
||||||
|
String response = httpGet(socket, "/ping");
|
||||||
|
assertTrue(response.startsWith("HTTP/1.1 200 OK"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyStore load(java.nio.file.Path path, String password) throws Exception {
|
||||||
|
KeyStore store = KeyStore.getInstance("PKCS12");
|
||||||
|
try (InputStream in = java.nio.file.Files.newInputStream(path)) { store.load(in, password.toCharArray()); }
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static javax.net.ssl.X509TrustManager trustAll() {
|
||||||
|
return new javax.net.ssl.X509TrustManager() {
|
||||||
|
public void checkClientTrusted(X509Certificate[] chain, String authType) {}
|
||||||
|
public void checkServerTrusted(X509Certificate[] chain, String authType) {}
|
||||||
|
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Multiple listeners, one app ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void multipleListeners_plainAndTlsServeTheSameApp(@TempDir java.nio.file.Path dir) throws Exception {
|
||||||
|
var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost"));
|
||||||
|
int plainPort = freePort();
|
||||||
|
int tlsPort = freePort();
|
||||||
|
app = FlashApp.create(FlashConfiguration.builder()
|
||||||
|
.listener(new FlashConfiguration.Listener(plainPort, "127.0.0.1", null))
|
||||||
|
.listener(new FlashConfiguration.Listener(tlsPort, "127.0.0.1", TlsConfig.keystore(ks, "changeit")))
|
||||||
|
.build());
|
||||||
|
app.get("/ping", (req, res) -> "pong");
|
||||||
|
app.start();
|
||||||
|
|
||||||
|
try (Socket socket = new Socket("127.0.0.1", plainPort)) {
|
||||||
|
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
|
||||||
|
socket.getOutputStream().write("GET /ping 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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
SSLSocketFactory factory = TestKeystores.trustAllClientContext().getSocketFactory();
|
||||||
|
try (SSLSocket socket = (SSLSocket) factory.createSocket("127.0.0.1", tlsPort)) {
|
||||||
|
String response = httpGet(socket, "/ping");
|
||||||
|
assertTrue(response.startsWith("HTTP/1.1 200 OK"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── KeyManager failure isolation ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Throws on its first invocation (any keyType), then delegates normally — simulates a
|
||||||
|
* one-off failure in a custom KeyManager (e.g. a failed DB lookup or on-demand cert
|
||||||
|
* issuance) without permanently breaking the listener. */
|
||||||
|
private static final class FlakyKeyManager extends X509ExtendedKeyManager {
|
||||||
|
private final X509ExtendedKeyManager delegate;
|
||||||
|
private final AtomicInteger calls = new AtomicInteger();
|
||||||
|
|
||||||
|
FlakyKeyManager(X509ExtendedKeyManager delegate) { this.delegate = delegate; }
|
||||||
|
|
||||||
|
private void maybeFail() {
|
||||||
|
if (calls.getAndIncrement() == 0) throw new RuntimeException("simulated KeyManager failure");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public String chooseEngineServerAlias(String keyType, Principal[] issuers, javax.net.ssl.SSLEngine engine) {
|
||||||
|
maybeFail();
|
||||||
|
return delegate.chooseEngineServerAlias(keyType, issuers, engine);
|
||||||
|
}
|
||||||
|
@Override public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) {
|
||||||
|
maybeFail();
|
||||||
|
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); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keyManagerFailure_isolatedToOneConnection_listenerServesTheNext(@TempDir java.nio.file.Path dir) throws Exception {
|
||||||
|
var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost"));
|
||||||
|
KeyStore store = load(ks, "changeit");
|
||||||
|
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
|
||||||
|
kmf.init(store, "changeit".toCharArray());
|
||||||
|
|
||||||
|
KeyManager[] managers = kmf.getKeyManagers();
|
||||||
|
for (int i = 0; i < managers.length; i++) {
|
||||||
|
if (managers[i] instanceof X509ExtendedKeyManager x509) managers[i] = new FlakyKeyManager(x509);
|
||||||
|
}
|
||||||
|
SSLContext ctx = SSLContext.getInstance("TLS");
|
||||||
|
ctx.init(managers, null, null);
|
||||||
|
|
||||||
|
int port = freePort();
|
||||||
|
app = FlashApp.create(FlashConfiguration.builder()
|
||||||
|
.port(port).host("127.0.0.1")
|
||||||
|
.tls(TlsConfig.ofContext(ctx))
|
||||||
|
.build());
|
||||||
|
app.get("/ping", (req, res) -> "pong");
|
||||||
|
app.start();
|
||||||
|
|
||||||
|
SSLSocketFactory factory = TestKeystores.trustAllClientContext().getSocketFactory();
|
||||||
|
|
||||||
|
// Connection 1: the KeyManager throws — handshake must fail, but must not take the
|
||||||
|
// listener down with it.
|
||||||
|
try (SSLSocket socket = (SSLSocket) factory.createSocket("127.0.0.1", port)) {
|
||||||
|
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
|
||||||
|
assertThrows(IOException.class, socket::startHandshake);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection 2: same listener, no reconfiguration — must succeed and serve normally.
|
||||||
|
try (SSLSocket socket = (SSLSocket) factory.createSocket("127.0.0.1", port)) {
|
||||||
|
String response = httpGet(socket, "/ping");
|
||||||
|
assertTrue(response.startsWith("HTTP/1.1 200 OK"));
|
||||||
|
assertTrue(response.endsWith("pong"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package dev.relism.flash.tls;
|
||||||
|
|
||||||
|
import javax.net.ssl.SSLContext;
|
||||||
|
import javax.net.ssl.TrustManager;
|
||||||
|
import javax.net.ssl.X509TrustManager;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.security.KeyManagementException;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.cert.X509Certificate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-only: builds self-signed PKCS12 keystores via the JDK's own {@code keytool} so TLS
|
||||||
|
* tests need no external certificate fixtures and no extra crypto test-dependency.
|
||||||
|
*/
|
||||||
|
public final class TestKeystores {
|
||||||
|
private TestKeystores() {}
|
||||||
|
|
||||||
|
public record Entry(String alias, String cn, String... sans) {
|
||||||
|
public static Entry of(String alias, String cn, String... sans) { return new Entry(alias, cn, sans); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Path build(Path dir, String fileName, String password, Entry... entries)
|
||||||
|
throws IOException, InterruptedException {
|
||||||
|
Path keystore = dir.resolve(fileName);
|
||||||
|
String keytool = Path.of(System.getProperty("java.home"), "bin", "keytool").toString();
|
||||||
|
for (Entry e : entries) {
|
||||||
|
List<String> cmd = new ArrayList<>(List.of(
|
||||||
|
keytool, "-genkeypair", "-noprompt",
|
||||||
|
"-alias", e.alias(),
|
||||||
|
"-keyalg", "RSA", "-keysize", "2048",
|
||||||
|
"-validity", "3650",
|
||||||
|
"-keystore", keystore.toString(),
|
||||||
|
"-storetype", "PKCS12",
|
||||||
|
"-storepass", password,
|
||||||
|
"-dname", "CN=" + e.cn()));
|
||||||
|
if (e.sans().length > 0) {
|
||||||
|
StringBuilder san = new StringBuilder();
|
||||||
|
for (String s : e.sans()) {
|
||||||
|
if (!san.isEmpty()) san.append(',');
|
||||||
|
san.append("dns:").append(s);
|
||||||
|
}
|
||||||
|
cmd.add("-ext");
|
||||||
|
cmd.add("SAN=" + san);
|
||||||
|
}
|
||||||
|
Process p = new ProcessBuilder(cmd).redirectErrorStream(true).start();
|
||||||
|
String output = new String(p.getInputStream().readAllBytes());
|
||||||
|
if (p.waitFor() != 0) throw new IOException("keytool failed: " + output);
|
||||||
|
}
|
||||||
|
return keystore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A client-side {@link SSLContext} that trusts any server certificate — self-signed test certs only. */
|
||||||
|
public static SSLContext trustAllClientContext() throws NoSuchAlgorithmException, KeyManagementException {
|
||||||
|
TrustManager trustAll = new X509TrustManager() {
|
||||||
|
public void checkClientTrusted(X509Certificate[] chain, String authType) {}
|
||||||
|
public void checkServerTrusted(X509Certificate[] chain, String authType) {}
|
||||||
|
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
|
||||||
|
};
|
||||||
|
SSLContext ctx = SSLContext.getInstance("TLS");
|
||||||
|
ctx.init(null, new TrustManager[] { trustAll }, null);
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package dev.relism.flash.tls;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import javax.net.ssl.SSLContext;
|
||||||
|
import javax.net.ssl.SSLParameters;
|
||||||
|
import javax.net.ssl.SSLServerSocket;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class TlsConfigTest {
|
||||||
|
|
||||||
|
private static SSLServerSocket unboundSocket(TlsConfig tls) throws IOException {
|
||||||
|
return (SSLServerSocket) tls.serverSocketFactory().createServerSocket();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keystore_pinsProtocolsToTls12And13(@TempDir Path dir) throws Exception {
|
||||||
|
Path ks = TestKeystores.build(dir, "id.p12", "changeit",
|
||||||
|
TestKeystores.Entry.of("only", "single.test"));
|
||||||
|
TlsConfig tls = TlsConfig.keystore(ks, "changeit");
|
||||||
|
|
||||||
|
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||||
|
tls.applyTo(socket);
|
||||||
|
List<String> protocols = Arrays.asList(socket.getSSLParameters().getProtocols());
|
||||||
|
assertTrue(protocols.contains("TLSv1.2"));
|
||||||
|
assertTrue(protocols.contains("TLSv1.3"));
|
||||||
|
assertFalse(protocols.contains("SSLv3"));
|
||||||
|
assertFalse(protocols.contains("TLSv1"));
|
||||||
|
assertFalse(protocols.contains("TLSv1.1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ofContext_appliesNoParameterOverlay() throws Exception {
|
||||||
|
SSLContext ctx = TestKeystores.trustAllClientContext(); // any valid SSLContext will do here
|
||||||
|
TlsConfig tls = TlsConfig.ofContext(ctx);
|
||||||
|
|
||||||
|
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||||
|
SSLParameters before = socket.getSSLParameters();
|
||||||
|
String[] protocolsBefore = before.getProtocols();
|
||||||
|
|
||||||
|
tls.applyTo(socket);
|
||||||
|
|
||||||
|
assertArrayEquals(protocolsBefore, socket.getSSLParameters().getProtocols(),
|
||||||
|
"ofContext must not narrow/override protocols set on the caller's SSLContext");
|
||||||
|
assertFalse(socket.getNeedClientAuth());
|
||||||
|
assertFalse(socket.getWantClientAuth());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ofContext_preservesCallerConfiguredAlpnAndProtocols() throws Exception {
|
||||||
|
// Mirrors the real use case this escape hatch exists for (TLS-ALPN-01 / RFC 8737):
|
||||||
|
// the caller sets its own ALPN protocol list — and, to make the point unambiguous,
|
||||||
|
// a protocol list *narrower* than what Flash's own keystore() path would pin — directly
|
||||||
|
// on the socket. applyTo() must not touch either. There is no SSLContext#setDefault-
|
||||||
|
// SSLParameters in the public JSSE API, so this per-socket SSLParameters is the only
|
||||||
|
// place such configuration can live; this test is the contract that makes it safe to
|
||||||
|
// rely on.
|
||||||
|
SSLContext ctx = TestKeystores.trustAllClientContext();
|
||||||
|
TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.OPTIONAL);
|
||||||
|
|
||||||
|
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||||
|
SSLParameters custom = socket.getSSLParameters();
|
||||||
|
custom.setApplicationProtocols(new String[] { "acme-tls/1", "http/1.1" });
|
||||||
|
custom.setProtocols(new String[] { "TLSv1.3" });
|
||||||
|
socket.setSSLParameters(custom);
|
||||||
|
|
||||||
|
tls.applyTo(socket);
|
||||||
|
|
||||||
|
SSLParameters after = socket.getSSLParameters();
|
||||||
|
assertArrayEquals(new String[] { "acme-tls/1", "http/1.1" }, after.getApplicationProtocols(),
|
||||||
|
"ofContext must not touch ALPN protocols the caller configured on its own socket");
|
||||||
|
assertArrayEquals(new String[] { "TLSv1.3" }, after.getProtocols(),
|
||||||
|
"ofContext must not widen/override the caller's own protocol list");
|
||||||
|
// clientAuth still applies — it is the caller's own explicit instruction through
|
||||||
|
// this API, not a Flash-imposed default. See TlsConfig's class Javadoc.
|
||||||
|
assertTrue(socket.getWantClientAuth());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clientAuth_none_makesNoClientAuthCall() throws Exception {
|
||||||
|
SSLContext ctx = TestKeystores.trustAllClientContext();
|
||||||
|
TlsConfig tls = TlsConfig.ofContext(ctx);
|
||||||
|
|
||||||
|
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||||
|
tls.applyTo(socket);
|
||||||
|
assertFalse(socket.getNeedClientAuth());
|
||||||
|
assertFalse(socket.getWantClientAuth());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clientAuth_require_setsNeedClientAuth() throws Exception {
|
||||||
|
SSLContext ctx = TestKeystores.trustAllClientContext();
|
||||||
|
TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.REQUIRE);
|
||||||
|
|
||||||
|
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||||
|
tls.applyTo(socket);
|
||||||
|
assertTrue(socket.getNeedClientAuth());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clientAuth_optional_setsWantClientAuth() throws Exception {
|
||||||
|
SSLContext ctx = TestKeystores.trustAllClientContext();
|
||||||
|
TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.OPTIONAL);
|
||||||
|
|
||||||
|
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||||
|
tls.applyTo(socket);
|
||||||
|
assertTrue(socket.getWantClientAuth());
|
||||||
|
assertFalse(socket.getNeedClientAuth());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user