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 {