diff --git a/core/pva/src/main/java/org/epics/pva/PVASettings.java b/core/pva/src/main/java/org/epics/pva/PVASettings.java
index 0c36568ede..1585311b3b 100644
--- a/core/pva/src/main/java/org/epics/pva/PVASettings.java
+++ b/core/pva/src/main/java/org/epics/pva/PVASettings.java
@@ -7,6 +7,7 @@
******************************************************************************/
package org.epics.pva;
+import java.io.File;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -259,6 +260,16 @@ public class PVASettings
+ /** Timeout [seconds] for waiting for certificate status PV to confirm VALID
+ *
+ *
After a TLS handshake, if the certificate includes a status PV extension
+ * (OID 1.3.6.1.4.1.37427.1), the connection will wait for the status PV
+ * to report VALID before allowing data operations.
+ * If not confirmed within this timeout, the connection enters degraded mode:
+ * data operations are released with a warning.
+ */
+ public static int EPICS_PVA_CERT_STATUS_TMO = 30;
+
/** Whether to allow PVA to use IPv6
*
*
If this is false then PVA will not attempt to
@@ -281,6 +292,15 @@ public class PVASettings
EPICS_PVA_TCP_SOCKET_TMO = get("EPICS_PVA_TCP_SOCKET_TMO", EPICS_PVA_TCP_SOCKET_TMO);
EPICS_PVA_MAX_ARRAY_FORMATTING = get("EPICS_PVA_MAX_ARRAY_FORMATTING", EPICS_PVA_MAX_ARRAY_FORMATTING);
EPICS_PVAS_TLS_KEYCHAIN = get("EPICS_PVAS_TLS_KEYCHAIN", EPICS_PVAS_TLS_KEYCHAIN);
+ if (EPICS_PVAS_TLS_KEYCHAIN.isEmpty())
+ {
+ final String xdg_server = getXdgPvaKeychainPath("server.p12");
+ if (!xdg_server.isEmpty())
+ {
+ EPICS_PVAS_TLS_KEYCHAIN = xdg_server;
+ logger.log(Level.CONFIG, "EPICS_PVAS_TLS_KEYCHAIN auto-discovered at " + xdg_server);
+ }
+ }
EPICS_PVAS_TLS_OPTIONS = get("EPICS_PVAS_TLS_OPTIONS", EPICS_PVAS_TLS_OPTIONS);
require_client_cert = EPICS_PVAS_TLS_OPTIONS.contains("client_cert=require");
EPICS_PVA_TLS_KEYCHAIN = get("EPICS_PVA_TLS_KEYCHAIN", EPICS_PVA_TLS_KEYCHAIN);
@@ -289,10 +309,20 @@ public class PVASettings
EPICS_PVA_TLS_KEYCHAIN = EPICS_PVAS_TLS_KEYCHAIN;
logger.log(Level.CONFIG, "EPICS_PVA_TLS_KEYCHAIN (empty) updated from EPICS_PVAS_TLS_KEYCHAIN");
}
+ if (EPICS_PVA_TLS_KEYCHAIN.isEmpty())
+ {
+ final String xdg_client = getXdgPvaKeychainPath("client.p12");
+ if (!xdg_client.isEmpty())
+ {
+ EPICS_PVA_TLS_KEYCHAIN = xdg_client;
+ logger.log(Level.CONFIG, "EPICS_PVA_TLS_KEYCHAIN auto-discovered at " + xdg_client);
+ }
+ }
EPICS_PVA_SEND_BUFFER_SIZE = get("EPICS_PVA_SEND_BUFFER_SIZE", EPICS_PVA_SEND_BUFFER_SIZE);
EPICS_PVA_FAST_BEACON_MIN = get("EPICS_PVA_FAST_BEACON_MIN", EPICS_PVA_FAST_BEACON_MIN);
EPICS_PVA_FAST_BEACON_MAX = get("EPICS_PVA_FAST_BEACON_MAX", EPICS_PVA_FAST_BEACON_MAX);
EPICS_PVA_MAX_BEACON_AGE = get("EPICS_PVA_MAX_BEACON_AGE", EPICS_PVA_MAX_BEACON_AGE);
+ EPICS_PVA_CERT_STATUS_TMO = get("EPICS_PVA_CERT_STATUS_TMO", EPICS_PVA_CERT_STATUS_TMO);
EPICS_PVA_ENABLE_IPV6 = get("EPICS_PVA_ENABLE_IPV6", EPICS_PVA_ENABLE_IPV6);
}
@@ -339,4 +369,63 @@ public static int get(final String name, final int default_value)
{
return Integer.parseInt(get(name, Integer.toString(default_value)));
}
+
+ /** PVA protocol version for XDG path construction.
+ *
+ *
Matches PVXS versionString() so that the Java client
+ * and PVXS share the same well-known keychain locations.
+ */
+ private static final String PVA_VERSION = "1.5";
+
+ /** Get XDG config home directory.
+ *
+ *
Uses {@code XDG_CONFIG_HOME} environment variable if set.
+ * Falls back to {@code $HOME/.config} on Unix or
+ * {@code %USERPROFILE%} on Windows,
+ * matching the PVXS {@code getXdgConfigHome()} behavior.
+ *
+ * @return XDG config home path, or empty string if home cannot be determined
+ */
+ private static String getXdgConfigHome()
+ {
+ final String xdg = System.getenv("XDG_CONFIG_HOME");
+ if (xdg != null && !xdg.isEmpty())
+ return xdg;
+
+ final String home = System.getProperty("user.home");
+ if (home == null || home.isEmpty())
+ return "";
+
+ if (System.getProperty("os.name", "").toLowerCase().startsWith("win"))
+ return home;
+
+ return home + File.separator + ".config";
+ }
+
+ /** Try to find a PVA keychain at the XDG well-known location.
+ *
+ *
Constructs the path
+ * {@code /pva//}
+ * and returns it if the file exists, otherwise returns empty string.
+ * This mirrors the PVXS fallback in {@code config.cpp} when
+ * {@code EPICS_PVA_TLS_KEYCHAIN} / {@code EPICS_PVAS_TLS_KEYCHAIN}
+ * are not configured.
+ *
+ * @param filename Keychain filename, e.g. "client.p12" or "server.p12"
+ * @return Absolute path to the keychain file, or empty string if not found
+ */
+ private static String getXdgPvaKeychainPath(final String filename)
+ {
+ final String config_home = getXdgConfigHome();
+ if (config_home.isEmpty())
+ return "";
+
+ final String path = config_home + File.separator + "pva"
+ + File.separator + PVA_VERSION
+ + File.separator + filename;
+ final File file = new File(path);
+ if (file.isFile() && file.canRead())
+ return path;
+ return "";
+ }
}
diff --git a/core/pva/src/main/java/org/epics/pva/client/ChannelSearch.java b/core/pva/src/main/java/org/epics/pva/client/ChannelSearch.java
index a50f84f01c..d1c5d1a1ce 100644
--- a/core/pva/src/main/java/org/epics/pva/client/ChannelSearch.java
+++ b/core/pva/src/main/java/org/epics/pva/client/ChannelSearch.java
@@ -187,6 +187,9 @@ public boolean equals(Object obj)
private final ClientUDPHandler udp;
+ /** When true, TLS is excluded from search requests and ignored in responses */
+ private final boolean tls_disabled;
+
/** Create ClientTCPHandler from IP address and 'tls' flag */
private final BiFunction tcp_provider;
@@ -206,15 +209,18 @@ public boolean equals(Object obj)
* @param udp_addresses UDP addresses to search
* @param tcp_provider Function that creates ClientTCPHandler for IP address and 'tls' flag
* @param name_server_addresses TCP addresses to search
+ * @param tls_disabled When true, exclude TLS from search protocol list
* @throws Exception on error
*/
public ChannelSearch(final ClientUDPHandler udp,
final List udp_addresses,
final BiFunction tcp_provider,
- final List name_server_addresses) throws Exception
+ final List name_server_addresses,
+ final boolean tls_disabled) throws Exception
{
this.udp = udp;
this.tcp_provider = tcp_provider;
+ this.tls_disabled = tls_disabled;
// Each bucket holds set of channels to search in that time slot
for (int i=0; i channels)
{
// Do we support TLS? This will be encoded in the search requests
- // to tell server if we can support TLS?
- final boolean tls = !PVASettings.EPICS_PVA_TLS_KEYCHAIN.isBlank();
+ // to tell server if we can support TLS.
+ // When tls_disabled, never advertise TLS so servers respond with TCP only.
+ final boolean tls = !tls_disabled && !PVASettings.EPICS_PVA_TLS_KEYCHAIN.isBlank();
// Search via TCP
for (AddressInfo name_server : name_server_addresses)
diff --git a/core/pva/src/main/java/org/epics/pva/client/ClientTCPHandler.java b/core/pva/src/main/java/org/epics/pva/client/ClientTCPHandler.java
index 5e4f9b4ab6..30f57f00ef 100644
--- a/core/pva/src/main/java/org/epics/pva/client/ClientTCPHandler.java
+++ b/core/pva/src/main/java/org/epics/pva/client/ClientTCPHandler.java
@@ -12,7 +12,10 @@
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.util.Collection;
+import java.util.Queue;
+import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
@@ -24,10 +27,15 @@
import javax.net.ssl.SSLSocket;
import org.epics.pva.PVASettings;
+import org.epics.pva.common.CertificateStatus;
+import org.epics.pva.common.CertificateStatusListener;
+import org.epics.pva.common.CertificateStatusMonitor;
import org.epics.pva.common.CommandHandlers;
import org.epics.pva.common.PVAHeader;
import org.epics.pva.common.RequestEncoder;
import org.epics.pva.common.SecureSockets;
+import org.epics.pva.common.SecureSockets.OwnCertInfo;
+import org.epics.pva.common.SecureSockets.TLSHandshakeInfo;
import org.epics.pva.common.TCPHandler;
import org.epics.pva.data.PVATypeRegistry;
import org.epics.pva.server.Guid;
@@ -108,6 +116,48 @@ class ClientTCPHandler extends TCPHandler
*/
private final AtomicBoolean connection_validated = new AtomicBoolean(false);
+ /** Own certificate status monitor, or null if own cert has no status PV extension */
+ private volatile CertificateStatus own_cert_status;
+
+ /** Peer certificate status monitor, or null if peer cert has no status PV extension */
+ private volatile CertificateStatus peer_cert_status;
+
+ /** Listener for own cert status updates */
+ private final CertificateStatusListener own_cert_listener = this::handleOwnCertStatusUpdate;
+
+ /** Listener for peer cert status updates */
+ private final CertificateStatusListener peer_cert_listener = this::handlePeerCertStatusUpdate;
+
+ /** Gate that completes when all required certificate statuses are confirmed VALID.
+ * Pre-completed if no certificates have status-PV extensions.
+ */
+ private volatile CompletableFuture cert_status_confirmed;
+
+ /** Individual gate for own cert, completed when VALID or no status PV extension */
+ private volatile CompletableFuture own_cert_gate;
+
+ /** Individual gate for peer cert, completed when VALID or no status PV extension */
+ private volatile CompletableFuture peer_cert_gate;
+
+ /** Degraded mode: status was VALID but reverted to UNKNOWN, hold new data operations */
+ private volatile boolean degraded = false;
+
+ /** Queued data operations waiting for initial cert status confirmation */
+ private final Queue queued_data_ops = new ConcurrentLinkedQueue<>();
+
+ /** A data operation that was held because cert status was not yet confirmed */
+ private static class QueuedDataOp
+ {
+ final RequestEncoder item;
+ final ResponseHandler handler;
+
+ QueuedDataOp(final RequestEncoder item, final ResponseHandler handler)
+ {
+ this.item = item;
+ this.handler = handler;
+ }
+ }
+
public ClientTCPHandler(final PVAClient client, final InetSocketAddress address, final Guid guid, final boolean tls) throws Exception
{
super(true);
@@ -138,6 +188,11 @@ protected boolean initializeSocket()
return false;
}
+ // Subscribe to certificate status PVs as early as possible.
+ // Own cert is known from context (same for all connections).
+ // Peer cert is known after TLS handshake (just completed in createClientSocket).
+ initCertStatusMonitoring();
+
// For default EPICS_CA_CONN_TMO: 30 sec, send echo at ~15 sec:
// Check every ~3 seconds
last_life_sign = last_message_sent = System.currentTimeMillis();
@@ -147,6 +202,54 @@ protected boolean initializeSocket()
return true;
}
+ /** Set up certificate status monitoring for own and peer certificates.
+ * Creates CompletableFuture gates and subscribes to status PVs.
+ * If no certs have status-PV extensions, the gate completes immediately.
+ */
+ private void initCertStatusMonitoring()
+ {
+ own_cert_gate = new CompletableFuture<>();
+ peer_cert_gate = new CompletableFuture<>();
+
+ if (tls)
+ {
+ // Own cert: already subscribed at keychain-read time in SecureSockets.initialize().
+ // Register a per-connection listener on the shared CertificateStatus.
+ final OwnCertInfo own_info = SecureSockets.getClientOwnCertInfo();
+ if (own_info != null && own_info.cert_status != null)
+ {
+ own_cert_status = own_info.cert_status;
+ own_cert_status.addListener(own_cert_listener);
+ }
+ else
+ own_cert_gate.complete(null);
+
+ // Peer cert: extracted from the just-completed TLS handshake
+ final TLSHandshakeInfo peer_info = TLSHandshakeInfo.fromHandshakenSocket((SSLSocket) socket);
+ if (peer_info != null && ! peer_info.status_pv_name.isEmpty())
+ {
+ logger.log(Level.FINE, () -> "Subscribing to peer cert status PV: " + peer_info.status_pv_name);
+ peer_cert_status = CertificateStatusMonitor.instance().checkCertStatus(
+ peer_info.peer_cert, peer_info.status_pv_name, peer_cert_listener);
+ }
+ else
+ peer_cert_gate.complete(null);
+ }
+ else
+ {
+ own_cert_gate.complete(null);
+ peer_cert_gate.complete(null);
+ }
+
+ cert_status_confirmed = CompletableFuture.allOf(own_cert_gate, peer_cert_gate);
+
+ cert_status_confirmed.thenRun(this::flushQueuedDataOps);
+
+ final int tmo = PVASettings.EPICS_PVA_CERT_STATUS_TMO;
+ if (! cert_status_confirmed.isDone())
+ timer.schedule(this::certStatusTimeout, tmo, TimeUnit.SECONDS);
+ }
+
@Override
public InetSocketAddress getRemoteAddress()
{
@@ -237,20 +340,6 @@ public PVATypeRegistry getTypeRegistry()
return types;
}
- /** Submit item to be sent to server and register handler for the response
- *
- * Handler will be invoked when the server replies to the request.
- * @param item {@link RequestEncoder}
- * @param handler {@link ResponseHandler}
- */
- public void submit(final RequestEncoder item, final ResponseHandler handler)
- {
- final int request_id = handler.getRequestID();
- response_handlers.put(request_id, handler);
- if (! submit(item))
- removeResponseHandler(request_id);
- }
-
@Override
protected void send(ByteBuffer buffer) throws Exception
{
@@ -339,10 +428,29 @@ void markAlive()
@Override
protected void onReceiverExited(final boolean running)
{
+ cleanupCertStatusMonitoring();
if (running)
client.shutdownConnection(this);
}
+ private void cleanupCertStatusMonitoring()
+ {
+ // Own cert is a shared subscription managed by SecureSockets.
+ // Only remove this connection's listener; do NOT call CertificateStatusMonitor.remove()
+ // which would close the shared subscription if we're the last listener.
+ if (own_cert_status != null)
+ {
+ own_cert_status.removeListener(own_cert_listener);
+ own_cert_status = null;
+ }
+ // Peer cert is per-connection, fully unsubscribe
+ if (peer_cert_status != null)
+ {
+ CertificateStatusMonitor.instance().remove(peer_cert_status, peer_cert_listener);
+ peer_cert_status = null;
+ }
+ }
+
@Override
protected void handleControlMessage(final byte command, final ByteBuffer buffer) throws Exception
{
@@ -438,6 +546,90 @@ void markValid() throws Exception
startSender();
}
+ private void handleOwnCertStatusUpdate(final CertificateStatus update)
+ {
+ handleCertStatusUpdate(update, own_cert_gate, "Own");
+ }
+
+ private void handlePeerCertStatusUpdate(final CertificateStatus update)
+ {
+ handleCertStatusUpdate(update, peer_cert_gate, "Peer");
+ }
+
+ private void handleCertStatusUpdate(final CertificateStatus update, final CompletableFuture gate, final String label)
+ {
+ if (update.isValid())
+ {
+ logger.log(Level.FINE, () -> label + " cert status VALID for " + this);
+ gate.complete(null);
+ if (degraded)
+ {
+ degraded = false;
+ logger.log(Level.FINE, () -> label + " cert status recovered from degraded mode for " + this);
+ flushQueuedDataOps();
+ }
+ }
+ else if (update.isUnrecoverable())
+ {
+ logger.log(Level.WARNING, () -> label + " cert status " + (update.isRevoked() ? "REVOKED" : "EXPIRED") + " for " + this + ", shutting down connection");
+ client.shutdownConnection(this);
+ }
+ else
+ {
+ // UNKNOWN or other non-VALID: degrade
+ logger.log(Level.WARNING, () -> label + " cert status UNKNOWN/degraded for " + this);
+ degraded = true;
+ }
+ }
+
+ private void certStatusTimeout()
+ {
+ if (! cert_status_confirmed.isDone())
+ {
+ logger.log(Level.WARNING, () -> "Certificate status not confirmed within " + PVASettings.EPICS_PVA_CERT_STATUS_TMO + "s for " + this + ", entering degraded mode");
+ degraded = true;
+ own_cert_gate.complete(null);
+ peer_cert_gate.complete(null);
+ }
+ }
+
+ /** Submit item to be sent to server and register handler for the response
+ *
+ * Handler will be invoked when the server replies to the request.
+ *
Data operations are held until certificate status is initially confirmed
+ * (VALID received or timeout reached).
+ * Once confirmed, operations flow even in degraded mode (UNKNOWN after timeout),
+ * because degraded means reduced trust, not blocked communication.
+ * @param item {@link RequestEncoder}
+ * @param handler {@link ResponseHandler}
+ */
+ public void submit(final RequestEncoder item, final ResponseHandler handler)
+ {
+ if (! cert_status_confirmed.isDone())
+ {
+ logger.log(Level.FINE, () -> "Queuing data operation (cert status pending) for " + this);
+ queued_data_ops.add(new QueuedDataOp(item, handler));
+ return;
+ }
+ final int request_id = handler.getRequestID();
+ response_handlers.put(request_id, handler);
+ if (! submit(item))
+ removeResponseHandler(request_id);
+ }
+
+ private void flushQueuedDataOps()
+ {
+ QueuedDataOp op;
+ while ((op = queued_data_ops.poll()) != null)
+ {
+ logger.log(Level.FINE, () -> "Flushing queued data operation for " + this);
+ final int request_id = op.handler.getRequestID();
+ response_handlers.put(request_id, op.handler);
+ if (! submit(op.item))
+ removeResponseHandler(request_id);
+ }
+ }
+
/** Close network socket and threads
* @param wait Wait for threads to end?
*/
@@ -445,6 +637,7 @@ void markValid() throws Exception
public void close(final boolean wait)
{
alive_check.cancel(false);
+ cleanupCertStatusMonitoring();
super.close(wait);
}
diff --git a/core/pva/src/main/java/org/epics/pva/client/PVAClient.java b/core/pva/src/main/java/org/epics/pva/client/PVAClient.java
index 517528bfbc..442f58c99e 100644
--- a/core/pva/src/main/java/org/epics/pva/client/PVAClient.java
+++ b/core/pva/src/main/java/org/epics/pva/client/PVAClient.java
@@ -62,6 +62,9 @@ public class PVAClient implements AutoCloseable
/** TCP handlers by server address */
private final ConcurrentHashMap tcp_handlers = new ConcurrentHashMap<>();
+ /** When true, all connections use plain TCP, ignoring TLS flags from search responses */
+ private final boolean tls_disabled;
+
private final AtomicInteger request_ids = new AtomicInteger();
/** Create a new PVAClient
@@ -80,6 +83,21 @@ public class PVAClient implements AutoCloseable
*/
public PVAClient() throws Exception
{
+ this(false);
+ }
+
+ /** Create a new PVAClient
+ *
+ * @param tls_disabled When true, all connections use plain TCP,
+ * ignoring the TLS flag in search responses.
+ * Used by the {@link org.epics.pva.common.CertificateStatusMonitor}
+ * to avoid infinite recursion: monitoring cert status requires a
+ * PVA connection, which must not itself require cert status monitoring.
+ * @throws Exception on error
+ */
+ public PVAClient(final boolean tls_disabled) throws Exception
+ {
+ this.tls_disabled = tls_disabled;
final List name_server_addresses = Network.parseAddresses(PVASettings.EPICS_PVA_NAME_SERVERS, PVASettings.EPICS_PVA_SERVER_PORT);
final List udp_search_addresses = Network.parseAddresses(PVASettings.EPICS_PVA_ADDR_LIST, PVASettings.EPICS_PVA_BROADCAST_PORT);
@@ -91,13 +109,14 @@ public PVAClient() throws Exception
// TCP traffic is handled by one ClientTCPHandler per address (IP, socket).
// Pass helper to channel search for getting such a handler.
+ // When tls_disabled, force use_tls=false regardless of what the server advertises.
final BiFunction tcp_provider = (the_addr, use_tls) ->
tcp_handlers.computeIfAbsent(the_addr, addr ->
{
try
{
// If absent, create with initial empty GUID
- return new ClientTCPHandler(this, addr, Guid.EMPTY, use_tls);
+ return new ClientTCPHandler(this, addr, Guid.EMPTY, tls_disabled ? false : use_tls);
}
catch (Exception ex)
{
@@ -106,7 +125,7 @@ public PVAClient() throws Exception
return null;
});
- search = new ChannelSearch(udp, udp_search_addresses, tcp_provider, name_server_addresses);
+ search = new ChannelSearch(udp, udp_search_addresses, tcp_provider, name_server_addresses, tls_disabled);
udp.start();
search.start();
@@ -243,6 +262,14 @@ void handleSearchResponse(final int channel_id, final InetSocketAddress server,
return;
}
+ // When TLS is disabled (e.g. inner cert-status client), skip TLS search responses.
+ // The server also listens on a plain TCP port and will send a separate response for that.
+ if (tls_disabled && tls)
+ {
+ logger.log(Level.FINE, () -> "Skipping TLS search response from " + server + " (TLS disabled)");
+ return;
+ }
+
// Reply for specific channel
final PVAChannel channel = search.unregister(channel_id);
// Late reply for search that was already satisfied?
@@ -268,11 +295,12 @@ void handleSearchResponse(final int channel_id, final InetSocketAddress server,
channel.setState(ClientChannelState.FOUND);
logger.log(Level.FINE, () -> "Reply for " + channel + " from " + (tls ? "TLS " : "TCP ") + server + " " + guid);
+ final boolean use_tls = tls_disabled ? false : tls;
final ClientTCPHandler tcp = tcp_handlers.computeIfAbsent(server, addr ->
{
try
{
- return new ClientTCPHandler(this, addr, guid, tls);
+ return new ClientTCPHandler(this, addr, guid, use_tls);
}
catch (Exception ex)
{
diff --git a/core/pva/src/main/java/org/epics/pva/common/CertificateStatus.java b/core/pva/src/main/java/org/epics/pva/common/CertificateStatus.java
index 1ffbef85f1..17ec5f3fbe 100644
--- a/core/pva/src/main/java/org/epics/pva/common/CertificateStatus.java
+++ b/core/pva/src/main/java/org/epics/pva/common/CertificateStatus.java
@@ -15,6 +15,10 @@
import java.util.BitSet;
import java.util.Date;
import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Level;
@@ -55,6 +59,14 @@ private static enum StatusOptions
UNKNOWN, VALID, PENDING, PENDING_APPROVAL, PENDING_RENEWAL, EXPIRED, REVOKED
}
+ /** Timer for OCSP response validity expiration */
+ private static final ScheduledExecutorService ocsp_timer = Executors.newSingleThreadScheduledExecutor(run ->
+ {
+ final Thread thread = new Thread(run, "OCSP Validity Timer");
+ thread.setDaemon(true);
+ return thread;
+ });
+
/** Certificate to check */
private final X509Certificate certificate;
@@ -64,6 +76,9 @@ private static enum StatusOptions
/** Status of the certificate */
private final AtomicReference status = new AtomicReference<>(StatusOptions.UNKNOWN);
+ /** Scheduled OCSP validity expiration, or null */
+ private volatile ScheduledFuture> ocsp_expiration;
+
/** Listeners to status changes */
private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>();
@@ -86,7 +101,7 @@ public String getPVName()
}
/** @param listener Listener to add (with initial update) */
- void addListener(final CertificateStatusListener listener)
+ public void addListener(final CertificateStatusListener listener)
{
listeners.add(listener);
// Send initial update
@@ -97,7 +112,7 @@ void addListener(final CertificateStatusListener listener)
/** @param listener Listener to remove
* @return Was that the last listener, can CertificateStatus be removed?
*/
- boolean removeListener(final CertificateStatusListener listener)
+ public boolean removeListener(final CertificateStatusListener listener)
{
if (! listeners.remove(listener))
throw new IllegalStateException("Unknown CertificateStatusListener");
@@ -110,6 +125,25 @@ public boolean isValid()
return status.get() == StatusOptions.VALID;
}
+ /** @return Is the certificate revoked? (unrecoverable) */
+ public boolean isRevoked()
+ {
+ return status.get() == StatusOptions.REVOKED;
+ }
+
+ /** @return Is the certificate expired? (unrecoverable) */
+ public boolean isExpired()
+ {
+ return status.get() == StatusOptions.EXPIRED;
+ }
+
+ /** @return Is the status unrecoverable (REVOKED or EXPIRED)? */
+ public boolean isUnrecoverable()
+ {
+ final StatusOptions s = status.get();
+ return s == StatusOptions.REVOKED || s == StatusOptions.EXPIRED;
+ }
+
/** PVAChannel connection handler, starts monitor */
private void handleConnection(final PVAChannel channel, final ClientChannelState state)
{
@@ -246,6 +280,7 @@ private void handleMonitor(final PVAChannel channel, final BitSet changes, final
logger.log(Level.FINER, "OCSP status is GOOD");
status.set(StatusOptions.VALID);
ocsp_confirmation = true;
+ scheduleOcspExpiration(until);
break;
}
else if (response_status instanceof RevokedStatus revoked)
@@ -276,6 +311,30 @@ else if (response_status instanceof RevokedStatus revoked)
notifyListeners();
}
+ private void scheduleOcspExpiration(final Date next_update)
+ {
+ final ScheduledFuture> prev = ocsp_expiration;
+ if (prev != null)
+ prev.cancel(false);
+
+ if (next_update == null)
+ return;
+
+ final long delay_ms = next_update.getTime() - System.currentTimeMillis();
+ if (delay_ms <= 0)
+ return;
+
+ logger.log(Level.FINER, () -> "Scheduling OCSP validity expiration for " + pv.getName() + " in " + delay_ms + " ms");
+ ocsp_expiration = ocsp_timer.schedule(() ->
+ {
+ if (status.compareAndSet(StatusOptions.VALID, StatusOptions.UNKNOWN))
+ {
+ logger.log(Level.WARNING, () -> "OCSP response for " + pv.getName() + " expired without renewal, status reverts to UNKNOWN");
+ notifyListeners();
+ }
+ }, delay_ms, TimeUnit.MILLISECONDS);
+ }
+
private void notifyListeners()
{
for (var listener : listeners)
@@ -287,6 +346,9 @@ void close()
{
if (! listeners.isEmpty())
throw new IllegalStateException("CertificateStatus(" + getPVName() + ") is still in use");
+ final ScheduledFuture> timer = ocsp_expiration;
+ if (timer != null)
+ timer.cancel(false);
pv.close();
}
diff --git a/core/pva/src/main/java/org/epics/pva/common/CertificateStatusMonitor.java b/core/pva/src/main/java/org/epics/pva/common/CertificateStatusMonitor.java
index 0838173c72..8631f987a1 100644
--- a/core/pva/src/main/java/org/epics/pva/common/CertificateStatusMonitor.java
+++ b/core/pva/src/main/java/org/epics/pva/common/CertificateStatusMonitor.java
@@ -9,6 +9,7 @@
import static org.epics.pva.PVASettings.logger;
+import java.security.cert.X509Certificate;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Level;
@@ -53,12 +54,16 @@ public class CertificateStatusMonitor
/** PVA Client used for all CERT:STATUS:... PVs */
private PVAClient client = null;
- /** Constructor of the singleton instance */
+ /** Constructor of the singleton instance.
+ * Creates a PVAClient with TLS disabled to avoid infinite recursion:
+ * cert status monitoring requires a PVA connection which must not
+ * itself trigger cert status monitoring.
+ */
private CertificateStatusMonitor()
{
try
{
- client = new PVAClient();
+ client = new PVAClient(true);
}
catch (Exception ex)
{
@@ -88,13 +93,23 @@ public static synchronized CertificateStatusMonitor instance()
*/
public synchronized CertificateStatus checkCertStatus(final TLSHandshakeInfo tls_info,final CertificateStatusListener listener)
{
- if (!tls_info.status_pv_name.startsWith("CERT:STATUS:"))
- throw new IllegalArgumentException("Need CERT:STATUS:... PV, got " + tls_info.status_pv_name);
+ return checkCertStatus(tls_info.peer_cert, tls_info.status_pv_name, listener);
+ }
+
+ /** @param certificate X.509 certificate to monitor
+ * @param status_pv_name CERT:STATUS:... PV name from the certificate's extension
+ * @param listener Listener to invoke for certificate status updates
+ * @return {@link CertificateStatus} to which we're subscribed, need to unsubscribe when no longer needed
+ */
+ public synchronized CertificateStatus checkCertStatus(final X509Certificate certificate, final String status_pv_name, final CertificateStatusListener listener)
+ {
+ if (!status_pv_name.startsWith("CERT:STATUS:"))
+ throw new IllegalArgumentException("Need CERT:STATUS:... PV, got " + status_pv_name);
- logger.log(Level.FINER, () -> "Checking " + tls_info.status_pv_name + " for '" + tls_info.name + "'");
+ logger.log(Level.FINER, () -> "Checking " + status_pv_name + " for '" + certificate.getSubjectX500Principal() + "'");
- final CertificateStatus cert_stat = certificate_states.computeIfAbsent(tls_info.status_pv_name,
- stat_pv_name -> new CertificateStatus(client, tls_info.peer_cert, tls_info.status_pv_name));
+ final CertificateStatus cert_stat = certificate_states.computeIfAbsent(status_pv_name,
+ stat_pv_name -> new CertificateStatus(client, certificate, status_pv_name));
cert_stat.addListener(listener);
return cert_stat;
diff --git a/core/pva/src/main/java/org/epics/pva/common/SecureSockets.java b/core/pva/src/main/java/org/epics/pva/common/SecureSockets.java
index d15d54957b..7b63e0a00e 100644
--- a/core/pva/src/main/java/org/epics/pva/common/SecureSockets.java
+++ b/core/pva/src/main/java/org/epics/pva/common/SecureSockets.java
@@ -14,6 +14,8 @@
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.security.KeyStore;
import java.security.Principal;
import java.security.cert.Certificate;
@@ -63,16 +65,65 @@ public class SecureSockets
/** X509 certificates loaded from the keychain mapped by principal name of the certificate */
public static Map keychain_x509_certificates = new ConcurrentHashMap<>();
- /** @param keychain_setting "/path/to/keychain;password"
- * @return {@link SSLContext} with 'keystore' and 'truststore' set to content of keystore
+ /** Own certificate info extracted from the client keychain during context creation, or null */
+ private static volatile OwnCertInfo client_own_cert_info;
+
+ /** Own certificate info extracted from the server keychain during context creation, or null */
+ private static volatile OwnCertInfo server_own_cert_info;
+
+ /** Info about the local (own) certificate's status PV extension and live subscription */
+ public static class OwnCertInfo
+ {
+ public final X509Certificate certificate;
+ public final String status_pv_name;
+
+ /** Live cert status subscription, set after subscribing in initialize(). Null before subscription. */
+ public volatile CertificateStatus cert_status;
+
+ OwnCertInfo(final X509Certificate certificate, final String status_pv_name)
+ {
+ this.certificate = certificate;
+ this.status_pv_name = status_pv_name;
+ }
+ }
+
+ /** @return Own-cert info for client TLS context, or null if no status PV extension */
+ public static OwnCertInfo getClientOwnCertInfo()
+ {
+ return client_own_cert_info;
+ }
+
+ /** @return Own-cert info for server TLS context, or null if no status PV extension */
+ public static OwnCertInfo getServerOwnCertInfo()
+ {
+ return server_own_cert_info;
+ }
+
+ /** Result of creating an SSL context, including own-cert status PV info */
+ private static class ContextInfo
+ {
+ final SSLContext context;
+ final OwnCertInfo own_cert_info;
+
+ ContextInfo(final SSLContext context, final OwnCertInfo own_cert_info)
+ {
+ this.context = context;
+ this.own_cert_info = own_cert_info;
+ }
+ }
+
+ /** @param keychain_setting "/path/to/keychain", "/path/to/keychain;password",
+ * or just "/path/to/keychain" with password in a separate *_PWD_FILE
+ * @param is_server true for server keychain (uses EPICS_PVAS_TLS_KEYCHAIN_PWD_FILE),
+ * false for client (uses EPICS_PVA_TLS_KEYCHAIN_PWD_FILE)
+ * @return {@link ContextInfo} with SSLContext and optional own-cert status PV info
* @throws Exception on error
*/
- private static SSLContext createContext(final String keychain_setting) throws Exception
+ private static ContextInfo createContext(final String keychain_setting, final boolean is_server) throws Exception
{
final String path;
final char[] pass;
- // We support the default "" empty as well as actual passwords, but not null for no password
final int sep = keychain_setting.indexOf(';');
if (sep > 0)
{
@@ -82,7 +133,7 @@ private static SSLContext createContext(final String keychain_setting) throws Ex
else
{
path = keychain_setting;
- pass = "".toCharArray();
+ pass = readKeychainPassword(is_server);
}
logger.log(Level.FINE, () -> "Loading keychain '" + path + "'");
@@ -90,7 +141,10 @@ private static SSLContext createContext(final String keychain_setting) throws Ex
final KeyStore key_store = KeyStore.getInstance("PKCS12");
key_store.load(new FileInputStream(path), pass);
- // Track each loaded certificate by its principal name
+ // Track each loaded certificate by its principal name,
+ // and extract own-cert status PV extension from key entries
+ OwnCertInfo own_cert_info = null;
+
for (String alias : Collections.list(key_store.aliases()))
{
if (key_store.isCertificateEntry(alias))
@@ -113,6 +167,48 @@ private static SSLContext createContext(final String keychain_setting) throws Ex
final String principal = x509.getSubjectX500Principal().toString();
logger.log(Level.FINE, "Keychain alias '" + alias + "' is X509 key and certificate for " + principal);
keychain_x509_certificates.put(principal, x509);
+
+ // Extract certificate-status-PV extension (OID 1.3.6.1.4.1.37427.1) from own cert
+ if (own_cert_info == null)
+ {
+ try
+ {
+ final byte[] ext_value = x509.getExtensionValue("1.3.6.1.4.1.37427.1");
+ final String status_pv = decodeDERString(ext_value);
+ if (! status_pv.isEmpty())
+ {
+ own_cert_info = new OwnCertInfo(x509, status_pv);
+ logger.log(Level.FINE, "Own certificate status PV: '" + status_pv + "'");
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.log(Level.WARNING, "Error extracting status PV from own certificate", ex);
+ }
+ }
+
+ // Add CA certs from the key entry's chain as trusted entries.
+ // Java's TrustManagerFactory only trusts trustedCertEntry aliases,
+ // not the CA chain attached to a keyEntry.
+ // PVXS does the equivalent in extractCAs() (openssl.cpp).
+ final Certificate[] chain = key_store.getCertificateChain(alias);
+ if (chain != null)
+ {
+ for (int i = 1; i < chain.length; i++)
+ {
+ if (chain[i] instanceof X509Certificate ca_cert)
+ {
+ final String ca_alias = "ca-chain-" + alias + "-" + i;
+ if (! key_store.containsAlias(ca_alias))
+ {
+ key_store.setCertificateEntry(ca_alias, ca_cert);
+ final String ca_name = ca_cert.getSubjectX500Principal().toString();
+ logger.log(Level.FINE, "Added CA from chain as trusted: " + ca_name);
+ keychain_x509_certificates.put(ca_name, ca_cert);
+ }
+ }
+ }
+ }
}
// Could print 'key', but jdk.event.security logger already logs the cert at FINE level
// and logging the key would show the private key
@@ -128,7 +224,30 @@ private static SSLContext createContext(final String keychain_setting) throws Ex
final SSLContext context = SSLContext.getInstance("TLS");
context.init(key_manager.getKeyManagers(), trust_manager.getTrustManagers(), null);
- return context;
+ return new ContextInfo(context, own_cert_info);
+ }
+
+ private static char[] readKeychainPassword(final boolean is_server)
+ {
+ final String env_name = is_server ? "EPICS_PVAS_TLS_KEYCHAIN_PWD_FILE"
+ : "EPICS_PVA_TLS_KEYCHAIN_PWD_FILE";
+ final String pwd_file = PVASettings.get(env_name, "");
+ if (! pwd_file.isEmpty())
+ {
+ try
+ {
+ final String password = Files.readString(Path.of(pwd_file)).trim();
+ logger.log(Level.FINE, () -> "Read keychain password from " + pwd_file);
+ return password.toCharArray();
+ }
+ catch (Exception ex)
+ {
+ logger.log(Level.WARNING, "Error reading password file " + pwd_file, ex);
+ }
+ }
+ // Java PKCS12: null skips encrypted sections (loses CA certs).
+ // Empty array attempts decryption with retry via NUL char fallback.
+ return new char[0];
}
private static synchronized void initialize() throws Exception
@@ -138,18 +257,43 @@ private static synchronized void initialize() throws Exception
if (! PVASettings.EPICS_PVAS_TLS_KEYCHAIN.isBlank())
{
- final SSLContext context = createContext(PVASettings.EPICS_PVAS_TLS_KEYCHAIN);
- tls_server_sockets = context.getServerSocketFactory();
+ final ContextInfo info = createContext(PVASettings.EPICS_PVAS_TLS_KEYCHAIN, true);
+ tls_server_sockets = info.context.getServerSocketFactory();
+ server_own_cert_info = info.own_cert_info;
+ subscribeOwnCertStatus(server_own_cert_info, "Server");
}
if (! PVASettings.EPICS_PVA_TLS_KEYCHAIN.isBlank())
{
- final SSLContext context = createContext(PVASettings.EPICS_PVA_TLS_KEYCHAIN);
- tls_client_sockets = context.getSocketFactory();
+ final ContextInfo info = createContext(PVASettings.EPICS_PVA_TLS_KEYCHAIN, false);
+ tls_client_sockets = info.context.getSocketFactory();
+ client_own_cert_info = info.own_cert_info;
+ subscribeOwnCertStatus(client_own_cert_info, "Client");
}
initialized = true;
}
+ /** Subscribe to own cert status PV immediately at keychain-read time.
+ * @param own_info OwnCertInfo extracted from keychain, or null
+ * @param label "Client" or "Server" for logging
+ */
+ private static void subscribeOwnCertStatus(final OwnCertInfo own_info, final String label)
+ {
+ if (own_info == null)
+ return;
+ logger.log(Level.FINE, () -> label + " subscribing to own cert status PV: " + own_info.status_pv_name);
+ own_info.cert_status = CertificateStatusMonitor.instance().checkCertStatus(
+ own_info.certificate, own_info.status_pv_name, update ->
+ {
+ if (update.isValid())
+ logger.log(Level.FINE, () -> label + " own cert status VALID");
+ else if (update.isUnrecoverable())
+ logger.log(Level.SEVERE, () -> label + " own cert status " + (update.isRevoked() ? "REVOKED" : "EXPIRED"));
+ else
+ logger.log(Level.WARNING, () -> label + " own cert status UNKNOWN");
+ });
+ }
+
/** Create server socket
* @param address IP address and port to which the socket will be bound
* @param tls Use TLS socket? Otherwise plain TCP
@@ -274,8 +418,13 @@ public static String decodeDERString(final byte[] der_value) throws Exception
{
if (der_value == null)
return "";
- // https://en.wikipedia.org/wiki/X.690#DER_encoding:
- // Type 4, length 0..127, characters
+ // X509Certificate.getExtensionValue() returns a DER OCTET STRING
+ // that wraps the actual extension content.
+ // The extension content itself is a DER-encoded string
+ // (OCTET STRING 0x04 or UTF8String 0x0C), so we must unwrap two layers:
+ // Outer: 0x04
+ // Inner: 0x04|0x0C
+ // https://en.wikipedia.org/wiki/X.690#DER_encoding
if (der_value.length < 2)
throw new Exception("Need DER type and size, only received " + der_value.length + " bytes");
if (der_value[0] != 0x04)
@@ -284,7 +433,20 @@ public static String decodeDERString(final byte[] der_value) throws Exception
throw new Exception("Can only handle strings of length 0-127, got " + der_value[1]);
if (der_value[1] != der_value.length-2)
throw new Exception("DER string length " + der_value[1] + " but " + (der_value.length-2) + " data items");
- return new String(der_value, 2, der_value[1]);
+
+ // Unwrap outer OCTET STRING to get the inner DER-encoded string
+ final int inner_offset = 2;
+ final int inner_len = der_value.length - 2;
+ if (inner_len < 2)
+ throw new Exception("Inner DER too short: " + inner_len + " bytes");
+ final byte inner_tag = der_value[inner_offset];
+ // Accept OCTET STRING (0x04), UTF8String (0x0C), or IA5String (0x16) as inner type
+ if (inner_tag != 0x04 && inner_tag != 0x0C && inner_tag != 0x16)
+ throw new Exception(String.format("Expected inner DER string type 0x04, 0x0C, or 0x16, got 0x%02X", inner_tag));
+ final int str_len = der_value[inner_offset + 1] & 0xFF;
+ if (str_len != inner_len - 2)
+ throw new Exception("Inner DER string length " + str_len + " but " + (inner_len-2) + " data bytes");
+ return new String(der_value, inner_offset + 2, str_len);
}
/** Get CN from principal
@@ -353,6 +515,24 @@ public static TLSHandshakeInfo fromSocket(final SSLSocket socket) throws Excepti
// but no obvious way to catch that
socket.startHandshake();
+ return extractPeerInfo(socket);
+ }
+
+ /** Extract peer certificate info from an already-handshaken SSL socket.
+ *
+ * Unlike {@link #fromSocket}, this does not call startHandshake()
+ * and is safe to use when the handshake was already performed.
+ *
+ * @param socket {@link SSLSocket} that has completed handshake
+ * @return {@link TLSHandshakeInfo} or null
+ */
+ public static TLSHandshakeInfo fromHandshakenSocket(final SSLSocket socket)
+ {
+ return extractPeerInfo(socket);
+ }
+
+ private static TLSHandshakeInfo extractPeerInfo(final SSLSocket socket)
+ {
try
{
// Log certificate chain, grep cert status PV name
diff --git a/core/pva/src/main/java/org/epics/pva/server/CreateChannelHandler.java b/core/pva/src/main/java/org/epics/pva/server/CreateChannelHandler.java
index 0abfa51f5c..fbed6a38c2 100644
--- a/core/pva/src/main/java/org/epics/pva/server/CreateChannelHandler.java
+++ b/core/pva/src/main/java/org/epics/pva/server/CreateChannelHandler.java
@@ -46,12 +46,15 @@ public void handleCommand(final ServerTCPHandler tcp, final ByteBuffer buffer) t
{
logger.log(Level.FINE, () -> "Channel create request '" + name + "' [CID " + cid + "]");
pv.addClient(tcp, cid);
- sendChannelCreated(tcp, pv, cid);
+ if (tcp.isClientCertStatusConfirmed())
+ sendChannelCreated(tcp, pv, cid);
+ else
+ tcp.queuePendingChannelCreate(pv, cid);
}
}
}
- private void sendChannelCreated(final ServerTCPHandler tcp, final ServerPV pv, int cid) throws Exception
+ static void sendChannelCreated(final ServerTCPHandler tcp, final ServerPV pv, int cid)
{
tcp.submit((version, buffer) ->
{
diff --git a/core/pva/src/main/java/org/epics/pva/server/ServerTCPHandler.java b/core/pva/src/main/java/org/epics/pva/server/ServerTCPHandler.java
index 76d160eec8..1f8084f064 100644
--- a/core/pva/src/main/java/org/epics/pva/server/ServerTCPHandler.java
+++ b/core/pva/src/main/java/org/epics/pva/server/ServerTCPHandler.java
@@ -13,8 +13,16 @@
import java.net.Socket;
import java.nio.ByteBuffer;
import java.util.Objects;
+import java.util.Queue;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
+import org.epics.pva.PVASettings;
import org.epics.pva.common.CertificateStatus;
import org.epics.pva.common.CertificateStatusListener;
import org.epics.pva.common.CertificateStatusMonitor;
@@ -49,6 +57,14 @@ class ServerTCPHandler extends TCPHandler
new RPCHandler(),
new CancelHandler());
+ /** Timer for cert status timeout */
+ private static final ScheduledExecutorService timer = Executors.newSingleThreadScheduledExecutor(run ->
+ {
+ final Thread thread = new Thread(run, "Server Cert Status Timer");
+ thread.setDaemon(true);
+ return thread;
+ });
+
/** Server that holds all the PVs */
private final PVAServer server;
@@ -67,6 +83,30 @@ class ServerTCPHandler extends TCPHandler
/** Handler for updates from {@link CertificateStatusMonitor} */
private final CertificateStatusListener certificate_status_listener;
+ /** Gate that completes when client cert status is confirmed VALID.
+ * Pre-completed if no cert status PV extension on the client cert.
+ */
+ private final CompletableFuture client_cert_status_gate;
+
+ /** Pending CreateChannel replies held until client cert status is confirmed */
+ private final Queue pending_channel_creates = new ConcurrentLinkedQueue<>();
+
+ /** Scheduled timeout for client cert status gate, or null */
+ private volatile ScheduledFuture> cert_status_timeout;
+
+ /** A CreateChannel reply deferred because client cert status is not yet confirmed */
+ static class PendingChannelCreate
+ {
+ final ServerPV pv;
+ final int cid;
+
+ PendingChannelCreate(final ServerPV pv, final int cid)
+ {
+ this.pv = pv;
+ this.cid = cid;
+ }
+ }
+
public ServerTCPHandler(final PVAServer server, final Socket client, final TLSHandshakeInfo tls_info) throws Exception
{
super(false);
@@ -80,25 +120,51 @@ public ServerTCPHandler(final PVAServer server, final Socket client, final TLSHa
server.register(this);
- certificate_status_listener = update->
- {
- final ClientAuthentication auth = getClientAuthentication();
- logger.log(Level.FINER, () -> "Certificate update for " + this + ": " + auth);
-
- // 1) Initial client_auth is Anonymous
- // When TLS connection starts,
- // 2a) CertificateStatusMonitor looks for CERT:STATUS:.., initial update has Anonymous from 1)
- // 2b) ValidationHandler will setClientAuthentication(x509 info from TLS)
- // If somebody called getClientAuthentication(), they'd get Anon/invalid because no "Valid" update, yet
- // 3) "Valid" update from CertificateStatusMonitor tends to happen just after that
- // --> Update all ServerPVs to send AccessRightsChange, in case there are already Server PVs
- server.updatePermissions(this, auth);
-
- // Channel created? CreateChannelHandler.sendChannelCreated sends initial AccessRightsChange
- // ServerPV.setWritable will send updated AccessRightsChange
- };
if (tls_info != null && !tls_info.status_pv_name.isEmpty())
+ {
+ client_cert_status_gate = new CompletableFuture<>();
+ certificate_status_listener = update->
+ {
+ final ClientAuthentication auth = getClientAuthentication();
+ logger.log(Level.FINER, () -> "Certificate update for " + this + ": " + auth);
+
+ // 1) Initial client_auth is Anonymous
+ // When TLS connection starts,
+ // 2a) CertificateStatusMonitor looks for CERT:STATUS:.., initial update has Anonymous from 1)
+ // 2b) ValidationHandler will setClientAuthentication(x509 info from TLS)
+ // If somebody called getClientAuthentication(), they'd get Anon/invalid because no "Valid" update, yet
+ // 3) "Valid" update from CertificateStatusMonitor tends to happen just after that
+ // --> Update all ServerPVs to send AccessRightsChange, in case there are already Server PVs
+ server.updatePermissions(this, auth);
+
+ if (update.isValid())
+ {
+ client_cert_status_gate.complete(null);
+ flushPendingChannelCreates();
+ }
+ else if (update.isUnrecoverable())
+ {
+ logger.log(Level.WARNING, () -> "Client cert " + (update.isRevoked() ? "REVOKED" : "EXPIRED") + " for " + this + ", shutting down connection");
+ client_cert_status_gate.complete(null);
+ server.shutdownConnection(this);
+ }
+
+ // Channel created? CreateChannelHandler.sendChannelCreated sends initial AccessRightsChange
+ // ServerPV.setWritable will send updated AccessRightsChange
+ };
certificate_status = CertificateStatusMonitor.instance().checkCertStatus(tls_info, certificate_status_listener);
+ cert_status_timeout = timer.schedule(this::handleCertStatusTimeout, PVASettings.EPICS_PVA_CERT_STATUS_TMO, TimeUnit.SECONDS);
+ }
+ else
+ {
+ client_cert_status_gate = CompletableFuture.completedFuture(null);
+ certificate_status_listener = update->
+ {
+ final ClientAuthentication auth = getClientAuthentication();
+ logger.log(Level.FINER, () -> "Certificate update for " + this + ": " + auth);
+ server.updatePermissions(this, auth);
+ };
+ }
startReceiver();
startSender();
@@ -191,9 +257,49 @@ ClientAuthentication getClientAuthentication()
return client_auth;
}
+ /** @return Is the client cert status confirmed (gate completed)? */
+ boolean isClientCertStatusConfirmed()
+ {
+ return client_cert_status_gate.isDone();
+ }
+
+ /** Queue a CreateChannel reply until client cert status is confirmed
+ * @param pv The server PV
+ * @param cid Client channel ID
+ */
+ void queuePendingChannelCreate(final ServerPV pv, final int cid)
+ {
+ logger.log(Level.FINE, () -> "Deferring CreateChannel reply for '" + pv + "' [CID " + cid + "] until client cert status confirmed");
+ pending_channel_creates.add(new PendingChannelCreate(pv, cid));
+ }
+
+ private void flushPendingChannelCreates()
+ {
+ PendingChannelCreate pending;
+ while ((pending = pending_channel_creates.poll()) != null)
+ {
+ final ServerPV pv = pending.pv;
+ final int cid = pending.cid;
+ logger.log(Level.FINE, () -> "Flushing deferred CreateChannel reply for '" + pv + "' [CID " + cid + "]");
+ CreateChannelHandler.sendChannelCreated(this, pv, cid);
+ }
+ }
+
+ private void handleCertStatusTimeout()
+ {
+ if (! client_cert_status_gate.isDone())
+ {
+ logger.log(Level.WARNING, () -> "Client cert status not confirmed within " + PVASettings.EPICS_PVA_CERT_STATUS_TMO + "s for " + this + ", releasing pending CreateChannel replies with degraded access");
+ client_cert_status_gate.complete(null);
+ flushPendingChannelCreates();
+ }
+ }
+
@Override
protected void onReceiverExited(final boolean running)
{
+ if (cert_status_timeout != null)
+ cert_status_timeout.cancel(false);
if (certificate_status != null)
{
CertificateStatusMonitor.instance().remove(certificate_status, certificate_status_listener);
diff --git a/core/pva/src/main/java/org/epics/pva/server/ServerTCPListener.java b/core/pva/src/main/java/org/epics/pva/server/ServerTCPListener.java
index ab9a00f534..dab130df37 100644
--- a/core/pva/src/main/java/org/epics/pva/server/ServerTCPListener.java
+++ b/core/pva/src/main/java/org/epics/pva/server/ServerTCPListener.java
@@ -24,8 +24,11 @@
import javax.net.ssl.SSLSocket;
import org.epics.pva.PVASettings;
+import org.epics.pva.common.CertificateStatus;
+import org.epics.pva.common.CertificateStatusListener;
import org.epics.pva.common.SecureSockets;
-import org.epics.pva.common.SecureSockets.TLSHandshakeInfo;;
+import org.epics.pva.common.SecureSockets.OwnCertInfo;
+import org.epics.pva.common.SecureSockets.TLSHandshakeInfo;
/** Listen to TCP connections
*
@@ -54,6 +57,12 @@ class ServerTCPListener
private volatile boolean running = true;
private volatile Thread listen_thread;
+ /** Server's own certificate status, or null if own cert has no status PV extension */
+ private volatile CertificateStatus server_own_cert_status;
+
+ /** Listener for server own cert status updates */
+ private final CertificateStatusListener server_own_cert_listener = this::handleServerOwnCertStatusUpdate;
+
public ServerTCPListener(final PVAServer server) throws Exception
{
this.server = server;
@@ -77,6 +86,19 @@ public ServerTCPListener(final PVAServer server) throws Exception
else
tls_server_socket = null;
+ // Subscribe to server's own cert status as early as possible
+ // (shared subscription created in SecureSockets.initialize(), register listener here)
+ if (tls)
+ {
+ final OwnCertInfo own_info = SecureSockets.getServerOwnCertInfo();
+ if (own_info != null && own_info.cert_status != null)
+ {
+ logger.log(Level.FINE, () -> "Registering listener on server own cert status PV: " + own_info.status_pv_name);
+ server_own_cert_status = own_info.cert_status;
+ server_own_cert_status.addListener(server_own_cert_listener);
+ }
+ }
+
// Start accepting connections
listen_thread = new Thread(this::listen, name);
listen_thread.setDaemon(true);
@@ -253,9 +275,35 @@ private void listen()
logger.log(Level.FINER, Thread.currentThread().getName() + " done.");
}
+ private void handleServerOwnCertStatusUpdate(final CertificateStatus update)
+ {
+ if (update.isValid())
+ {
+ logger.log(Level.FINE, "Server own cert status VALID");
+ }
+ else if (update.isUnrecoverable())
+ {
+ logger.log(Level.SEVERE, () -> "Server own cert status " + (update.isRevoked() ? "REVOKED" : "EXPIRED") + ", stopping TLS listener");
+ // Stop accepting new TLS connections; existing connections are not killed
+ running = false;
+ }
+ else
+ {
+ logger.log(Level.WARNING, "Server own cert status UNKNOWN/degraded, continuing at degraded trust");
+ }
+ }
+
public void close()
{
running = false;
+
+ // Remove own cert status listener (shared subscription, don't unsubscribe)
+ if (server_own_cert_status != null)
+ {
+ server_own_cert_status.removeListener(server_own_cert_listener);
+ server_own_cert_status = null;
+ }
+
// Close sockets, wait a little for threads to exit
try
{