diff --git a/.github/workflows/continuous-integration-workflow.yml b/.github/workflows/continuous-integration-workflow.yml index 5db20251e9..855b827f9d 100644 --- a/.github/workflows/continuous-integration-workflow.yml +++ b/.github/workflows/continuous-integration-workflow.yml @@ -25,6 +25,12 @@ jobs: cache: maven - name: Build with Maven run: ./mvnw -B package -Pcoverage --file pom.xml + - name: Upload failed test reports + uses: actions/upload-artifact@v3 + if: always() + with: + name: surefire-reports_java${{ matrix.java }} + path: xmppserver/target/surefire-reports - name: Upload distribution if: ${{ matrix.distribution == 'zulu' }} uses: actions/upload-artifact@v3 diff --git a/i18n/src/main/resources/openfire_i18n.properties b/i18n/src/main/resources/openfire_i18n.properties index 40731ad8c8..865d2faff6 100644 --- a/i18n/src/main/resources/openfire_i18n.properties +++ b/i18n/src/main/resources/openfire_i18n.properties @@ -2012,6 +2012,7 @@ connection.advanced.settings.clientauth.info=In addition to requiring peers to u connection.advanced.settings.clientauth.label_disabled=Disabled - Peer certificates are not verified. connection.advanced.settings.clientauth.label_wanted=Wanted - Peer certificates are verified, but only when they are presented by the peer. connection.advanced.settings.clientauth.label_needed=Needed - A connection cannot be established if the peer does not present a valid certificate. +connection.advanced.settings.clientauth.label_strict_cert_validation=If attempting to validate a certificate fails, the connection is closed and not attempted via dialback authentication. connection.advanced.settings.certchain.boxtitle=Certificate chain checking connection.advanced.settings.certchain.info=These options configure some aspects of the verification/validation of the certificates that are presented by peers while setting up encrypted connections. connection.advanced.settings.certchain.label_selfsigned=Allow peer certificates to be self-signed. diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/BlockingAcceptingMode.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/BlockingAcceptingMode.java index 4b61d89152..6f1bfbb000 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/BlockingAcceptingMode.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/BlockingAcceptingMode.java @@ -32,10 +32,12 @@ * @deprecated Old, pre NIO / MINA code. Should not be used as NIO offers better performance */ @Deprecated -class BlockingAcceptingMode extends SocketAcceptingMode { +public class BlockingAcceptingMode extends SocketAcceptingMode { private static final Logger Log = LoggerFactory.getLogger(BlockingAcceptingMode.class); + private SocketReader reader; + protected BlockingAcceptingMode(int tcpPort, InetAddress bindInterface, boolean directTLS) throws IOException { super(directTLS); serverSocket = new ServerSocket(tcpPort, -1, bindInterface); @@ -53,7 +55,7 @@ public void run() { if (sock != null) { Log.debug("Connect " + sock.toString()); - SocketReader reader = createServerSocketReader( sock, false, true ); + reader = createServerSocketReader( sock, false, true ); Thread thread = new Thread(reader, reader.getName()); thread.setDaemon(true); thread.setPriority(Thread.NORM_PRIORITY); @@ -71,4 +73,14 @@ public void run() { } } } + + /** + * The last socket reader that was created (if any). + * + * This is intended to be used for unit testing purposes only. + */ + public SocketReader getLastReader() + { + return reader; + } } diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/DNSUtil.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/DNSUtil.java index 258eeb44a1..6c3112544e 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/DNSUtil.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/DNSUtil.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2004-2008 Jive Software, 2022 Ignite Realtime Foundation. All rights reserved. + * Copyright (C) 2004-2008 Jive Software, 2022-2023 Ignite Realtime Foundation. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -345,7 +345,7 @@ public static class HostAddress implements Serializable { private final int port; private final boolean directTLS; - private HostAddress(String host, int port, boolean directTLS) { + public HostAddress(String host, int port, boolean directTLS) { // Host entries in DNS should end with a ".". if (host.endsWith(".")) { this.host = host.substring(0, host.length()-1); diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/ServerSocketReader.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/ServerSocketReader.java index 99378e41f4..2539173088 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/ServerSocketReader.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/ServerSocketReader.java @@ -22,6 +22,7 @@ import org.jivesoftware.openfire.auth.UnauthorizedException; import org.jivesoftware.openfire.event.ServerSessionEventDispatcher; import org.jivesoftware.openfire.interceptor.PacketRejectedException; +import org.jivesoftware.openfire.server.ServerDialback; import org.jivesoftware.openfire.session.LocalIncomingServerSession; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -195,7 +196,10 @@ String getNamespace() { @Override public String getExtraNamespaces() { - return "xmlns:db=\"jabber:server:dialback\""; + if (ServerDialback.isEnabled() || ServerDialback.isEnabledForSelfSigned()) { + return "xmlns:db=\"jabber:server:dialback\""; + } + return super.getExtraNamespaces(); } @Override diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SocketAcceptThread.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SocketAcceptThread.java index b19bd316b2..24ae42e9d0 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SocketAcceptThread.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SocketAcceptThread.java @@ -44,12 +44,12 @@ public class SocketAcceptThread extends Thread { public SocketAcceptThread( int tcpPort, InetAddress bindInterface, boolean directTLS ) throws IOException { super("Socket Listener at port " + tcpPort + ( directTLS ? " (direct TLS)" : "")); - this.tcpPort = tcpPort; this.bindInterface = bindInterface; this.directTLS = directTLS; // Set the blocking reading mode to use acceptingMode = new BlockingAcceptingMode(tcpPort, bindInterface, directTLS); + this.tcpPort = acceptingMode.serverSocket.getLocalPort(); } /** @@ -88,4 +88,13 @@ public void run() { // We stopped accepting new connections so close the listener shutdown(); } + + /** + * The Socket Accepting Mode for this thread. This is exposed for unit testing purposes. It is unlikely that this + * should be used elsewhere. + */ + public SocketAcceptingMode getAcceptingMode() + { + return acceptingMode; + } } diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SocketReader.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SocketReader.java index a76fa200d9..1066fd0678 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SocketReader.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SocketReader.java @@ -520,4 +520,9 @@ abstract boolean createSession(String namespace) throws UnauthorizedException, public String getExtraNamespaces() { return null; } + + public LocalSession getSession() + { + return session; + } } diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SocketReadingMode.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SocketReadingMode.java index 44a12e3aec..d99a23172f 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SocketReadingMode.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SocketReadingMode.java @@ -77,13 +77,13 @@ protected boolean negotiateTLS() { socketReader.connection.startTLS(false, false); } catch (SSLHandshakeException e) { - // RFC3620, section 5.4.3.2 "STARTTLS Failure" - close the socket *without* sending any more data ( nor ). + // RFC6120, section 5.4.3.2 "STARTTLS Failure" - close the socket *without* sending any more data ( nor ). Log.info( "STARTTLS negotiation (with: {}) failed.", socketReader.connection, e ); socketReader.connection.forceClose(); return false; } catch (IOException | RuntimeException e) { - // RFC3620, section 5.4.2.2 "Failure case" - Send a element, then close the socket. + // RFC6120, section 5.4.2.2 "Failure case" - Send a element, then close the socket. Log.warn( "An exception occurred while performing STARTTLS negotiation (with: {})", socketReader.connection, e); socketReader.connection.deliverRawText(""); socketReader.connection.close(); diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/server/ServerDialback.java b/xmppserver/src/main/java/org/jivesoftware/openfire/server/ServerDialback.java index c27a9bd837..abc62ee790 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/server/ServerDialback.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/server/ServerDialback.java @@ -28,6 +28,7 @@ import org.jivesoftware.openfire.spi.BasicStreamIDFactory; import org.jivesoftware.openfire.spi.ConnectionType; import org.jivesoftware.util.JiveGlobals; +import org.jivesoftware.util.StreamErrorException; import org.jivesoftware.util.StringUtils; import org.jivesoftware.util.cache.Cache; import org.jivesoftware.util.cache.CacheFactory; @@ -196,6 +197,11 @@ public LocalOutgoingServerSession createOutgoingSession(int port) { log.debug( "Creating new outgoing session..." ); + if (!ServerDialback.isEnabled() && !ServerDialback.isEnabledForSelfSigned()) { + log.info("Unable to create new outgoing session: Dialback has been disabled by configuration."); + return null; + } + String hostname = null; int realPort = port; try { @@ -319,6 +325,11 @@ public boolean authenticateDomain(OutgoingServerSocketReader socketReader, Strin log.debug( "Authenticating domain ..." ); + if (!ServerDialback.isEnabled() && !ServerDialback.isEnabledForSelfSigned()) { + log.info("Failed to authenticate domain: Dialback has been disabled by configuration."); + return false; + } + String key = AuthFactory.createDigest( id, getSecretkey() ); synchronized (socketReader) { @@ -389,6 +400,12 @@ private void sendDialbackKey(String key) { */ public LocalIncomingServerSession createIncomingSession(XMPPPacketReader reader) throws IOException, XmlPullParserException { + + if (!ServerDialback.isEnabled() && !ServerDialback.isEnabledForSelfSigned()) { + Log.info("Server Dialback: disallowing functionality as it has been disabled by configuration."); + return null; + } + XmlPullParser xpp = reader.getXPPParser(); StringBuilder sb; if ("jabber:server:dialback".equals(xpp.getNamespace("db"))) { @@ -411,11 +428,12 @@ public LocalIncomingServerSession createIncomingSession(XMPPPacketReader reader) String hostname = doc.attributeValue("from"); String recipient = doc.attributeValue("to"); Log.debug("ServerDialback: RS - Validating remote domain for incoming session from {} to {}", hostname, recipient); - if (validateRemoteDomain(doc, streamID)) { + try { + validateRemoteDomain(doc, streamID); Log.debug("ServerDialback: RS - Validation of remote domain for incoming session from {} to {} was successful.", hostname, recipient); // Create a server Session for the remote server LocalIncomingServerSession session = sessionManager. - createIncomingServerSession(connection, streamID, hostname); + createIncomingServerSession(connection, streamID, hostname); // Add the validated domain as a valid domain session.addValidatedDomain(hostname); session.setAuthenticationMethod(ServerSession.AuthenticationMethod.DIALBACK); @@ -425,8 +443,13 @@ public LocalIncomingServerSession createIncomingSession(XMPPPacketReader reader) // After the session has been created, inform all listeners as well. ServerSessionEventDispatcher.dispatchEvent(session, ServerSessionEventDispatcher.EventType.session_created); return session; - } else { - Log.debug("ServerDialback: RS - Validation of remote domain for incoming session from {} to {} was not successful.", hostname, recipient); + } catch (StreamErrorException e) { + Log.debug("ServerDialback: RS - Validation of remote domain for incoming session from {} to {} was not successful.", hostname, recipient, e); + connection.close(e.getStreamError()); + return null; + } catch (Exception e) { + Log.debug("ServerDialback: RS - Validation of remote domain for incoming session from {} to {} was not successful.", hostname, recipient, e); + connection.close(); return null; } } @@ -492,9 +515,10 @@ protected void dialbackError(String from, String to, PacketError err) { * * @param doc the request for validating the new domain. * @param streamID the stream id generated by this server for the Originating Server. - * @return true if the requested domain is valid. + * @throws StreamErrorException when validation did not succeed. */ - public boolean validateRemoteDomain(Element doc, StreamID streamID) { + public void validateRemoteDomain(Element doc, StreamID streamID) throws StreamErrorException, ServerDialbackErrorException, ServerDialbackKeyInvalidException + { StringBuilder sb; String recipient = doc.attributeValue("to"); String remoteDomain = doc.attributeValue("from"); @@ -502,25 +526,20 @@ public boolean validateRemoteDomain(Element doc, StreamID streamID) { final Logger log = LoggerFactory.getLogger( Log.getName() + "[Acting as Receiving Server: Validate domain: " + recipient + "(id " + streamID + ") for OS: " + remoteDomain + "]" ); log.debug( "Validating domain..."); - if (connection.getTlsPolicy() == Connection.TLSPolicy.required && - !connection.isEncrypted()) { - connection.deliverRawText(new StreamError(StreamError.Condition.policy_violation).toXML()); - // Close the underlying connection - connection.close(); - return false; + + if (!ServerDialback.isEnabled() && !ServerDialback.isEnabledForSelfSigned()) { + throw new StreamErrorException(new StreamError(StreamError.Condition.policy_violation, "Dialback has been disabled by configuration.")); + } + + if (connection.getTlsPolicy() == Connection.TLSPolicy.required && !connection.isEncrypted()) { + throw new StreamErrorException(new StreamError(StreamError.Condition.policy_violation, "Local server configuration dictates that Server Dialback can be negotiated only after the connection has been encrypted.")); } if (!RemoteServerManager.canAccess(remoteDomain)) { - connection.deliverRawText(new StreamError(StreamError.Condition.policy_violation).toXML()); - // Close the underlying connection - connection.close(); - log.debug( "Unable to validate domain: Remote domain is not allowed to establish a connection to this server." ); - return false; + throw new StreamErrorException(new StreamError(StreamError.Condition.policy_violation, "Remote domain is not allowed to establish a connection to this server.")); } else if (isHostUnknown(recipient)) { - dialbackError(recipient, remoteDomain, new PacketError(PacketError.Condition.item_not_found, PacketError.Type.cancel, "Service not hosted here")); - log.debug( "Unable to validate domain: recipient not recognized as a local domain." ); - return false; + throw new ServerDialbackErrorException(recipient, remoteDomain, new PacketError(PacketError.Condition.item_not_found, PacketError.Type.cancel, "Service not hosted here")); } else { log.debug( "Check if the remote domain already has a connection to the target domain/subdomain" ); @@ -531,24 +550,14 @@ else if (isHostUnknown(recipient)) { } } if (alreadyExists && !sessionManager.isMultipleServerConnectionsAllowed()) { - dialbackError(recipient, remoteDomain, new PacketError(PacketError.Condition.resource_constraint, PacketError.Type.cancel, "Incoming session already exists")); - log.debug( "Unable to validate domain: An incoming connection already exists from this remote domain, and multiple connections are not allowed." ); - return false; + throw new ServerDialbackErrorException(recipient, remoteDomain, new PacketError(PacketError.Condition.resource_constraint, PacketError.Type.cancel, "Incoming session already exists")); } else { log.debug( "Checking to see if the remote server provides stronger authentication based on SASL. If that's the case, dialback-based authentication can be skipped." ); if (SASLAuthentication.verifyCertificates(connection.getPeerCertificates(), remoteDomain, true)) { log.debug( "Host authenticated based on SASL. Weaker dialback-based authentication is skipped." ); - sb = new StringBuilder(); - sb.append(""); - connection.deliverRawText(sb.toString()); - log.debug( "Domain validated successfully!" ); - return true; + return; } log.debug( "Unable to authenticate host based on stronger SASL. Proceeding with dialback..." ); @@ -559,9 +568,7 @@ else if (isHostUnknown(recipient)) { if ( socketToXmppDomain == null ) { - log.debug( "Unable to validate domain: No server available for verifying key of remote server." ); - dialbackError(recipient, remoteDomain, new PacketError(PacketError.Condition.remote_server_not_found, PacketError.Type.cancel, "Unable to connect to authoritative server")); - return false; + throw new ServerDialbackErrorException(recipient, remoteDomain, new PacketError(PacketError.Condition.remote_server_not_found, PacketError.Type.cancel, "No server available for verifying key of remote server.")); } Socket socket = socketToXmppDomain.getKey(); @@ -617,39 +624,23 @@ else if (isHostUnknown(recipient)) { } switch(result) { - case valid: - case invalid: - boolean valid = (result == VerifyResult.valid); - log.debug( "Dialback key is " + (valid? "valid":"invalid") + ". Sending verification result to remote domain." ); - sb = new StringBuilder(); - sb.append(""); - connection.deliverRawText(sb.toString()); - - if (!valid) { - log.debug( "Close the underlying connection as key verification failed." ); - connection.close(); - log.debug( "Unable to validate domain: dialback key is invalid." ); - return false; - } else { + case valid: log.debug( "Successfully validated domain!" ); - return true; - } - default: - break; + return; + + case invalid: + throw new ServerDialbackKeyInvalidException(recipient, remoteDomain); + + default: + break; } - log.debug( "Unable to validate domain: key verification did not complete (the AS likely returned an error or a time out occurred)." ); - dialbackError( recipient, remoteDomain, new PacketError( PacketError.Condition.remote_server_timeout, PacketError.Type.cancel, "Authoritative server returned error" ) ); - return false; + throw new ServerDialbackErrorException(recipient, remoteDomain, new PacketError( PacketError.Condition.remote_server_timeout, PacketError.Type.cancel, "Key verification did not complete (the Authoritative Server likely returned an error or a time out occurred).")); + } + catch (ServerDialbackKeyInvalidException | ServerDialbackErrorException e) { + throw e; } catch (Exception e) { - dialbackError(recipient, remoteDomain, new PacketError(PacketError.Condition.remote_server_timeout, PacketError.Type.cancel, "Authoritative server failed")); - log.warn( "Unable to validate domain: An exception occurred while verifying the dialback key.", e ); - return false; + throw new ServerDialbackErrorException(recipient, remoteDomain, new PacketError(PacketError.Condition.remote_server_timeout, PacketError.Type.cancel, "Authoritative server failed"), e); } } } @@ -916,6 +907,20 @@ public static boolean verifyReceivedKey(Element doc, Connection connection) { // represented by the Receiving Server when opening the TCP connection, then // generate an stream error condition + if (!ServerDialback.isEnabled() && !ServerDialback.isEnabledForSelfSigned()) { + Log.info("Unable to verify the Dialback key as Dialback has been disabled by configuration."); + StringBuilder sb = new StringBuilder(); + sb.append(""); + sb.append(""); + sb.append(""); + connection.deliverRawText(sb.toString()); + return false; + } + // Verify the received key // Created the expected key based on the received ID value and the shared secret String expectedKey = AuthFactory.createDigest(streamID.getID(), getSecretkey()); diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/server/ServerDialbackErrorException.java b/xmppserver/src/main/java/org/jivesoftware/openfire/server/ServerDialbackErrorException.java new file mode 100644 index 0000000000..a0bd8b522b --- /dev/null +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/server/ServerDialbackErrorException.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2023 Ignite Realtime Foundation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.openfire.server; + +import org.dom4j.Document; +import org.dom4j.DocumentHelper; +import org.dom4j.Element; +import org.xmpp.packet.PacketError; + +/** + * Representation of an error result of the Server Dialback authentication mechanism. + * + * @author Guus der Kinderen, guus@goodbytes.nl + */ +public class ServerDialbackErrorException extends Exception +{ + private final String from; + private final String to; + private final PacketError error; + + public ServerDialbackErrorException(String from, String to, PacketError error) + { + super(); + this.from = from; + this.to = to; + this.error = error; + } + + public ServerDialbackErrorException(String from, String to, PacketError error, Throwable e) + { + super(e); + this.from = from; + this.to = to; + this.error = error; + } + + public String getFrom() + { + return from; + } + + public String getTo() + { + return to; + } + + public PacketError getError() + { + return error; + } + + public Element toXML() + { + final Document outbound = DocumentHelper.createDocument(); + final Element root = outbound.addElement("root"); + root.addNamespace("db", "urn:xmpp:features:dialback"); + final Element result = root.addElement("db:result"); + result.addAttribute("from", from); + result.addAttribute("to", to); + result.addAttribute("type", "error"); + result.add(error.getElement()); + + return result; + } +} diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/server/ServerDialbackKeyInvalidException.java b/xmppserver/src/main/java/org/jivesoftware/openfire/server/ServerDialbackKeyInvalidException.java new file mode 100644 index 0000000000..0d93662b53 --- /dev/null +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/server/ServerDialbackKeyInvalidException.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2023 Ignite Realtime Foundation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.openfire.server; + +import org.dom4j.Document; +import org.dom4j.DocumentHelper; +import org.dom4j.Element; + +/** + * Representation of an invalid-key result of the Server Dialback authentication mechanism. + * + * @author Guus der Kinderen, guus@goodbytes.nl + */ +public class ServerDialbackKeyInvalidException extends Exception +{ + private final String from; + private final String to; + + public ServerDialbackKeyInvalidException(String from, String to) + { + super(); + this.from = from; + this.to = to; + } + + public String getFrom() + { + return from; + } + + public String getTo() + { + return to; + } + + public Element toXML() { + final Document outbound = DocumentHelper.createDocument(); + final Element root = outbound.addElement("root"); + root.addNamespace("db", "urn:xmpp:features:dialback"); + final Element result = root.addElement("db:result"); + result.addAttribute("from", from); + result.addAttribute("to", to); + result.addAttribute("type", "invalid"); + + return result; + } +} diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/session/ConnectionSettings.java b/xmppserver/src/main/java/org/jivesoftware/openfire/session/ConnectionSettings.java index b5af333878..80c08eafcb 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/session/ConnectionSettings.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/session/ConnectionSettings.java @@ -101,6 +101,8 @@ public static final class Server { public static final String AUTH_PER_CLIENTCERT_POLICY = "xmpp.server.cert.policy"; public static final String ALLOW_ANONYMOUS_OUTBOUND_DATA = "xmpp.server.allow-anonymous-outbound-data"; public static final String IDLE_TIMEOUT_PROPERTY = "xmpp.server.idle"; + + public static final String STRICT_CERTIFICATE_VALIDATION = "xmpp.socket.ssl.certificate.strict-validation"; } public static final class Multiplex { diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalIncomingServerSession.java b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalIncomingServerSession.java index bd53444cde..1bf3958cc4 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalIncomingServerSession.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalIncomingServerSession.java @@ -15,6 +15,8 @@ */ package org.jivesoftware.openfire.session; +import org.dom4j.Document; +import org.dom4j.DocumentHelper; import org.dom4j.Element; import org.dom4j.io.XMPPPacketReader; import org.jivesoftware.openfire.Connection; @@ -25,8 +27,11 @@ import org.jivesoftware.openfire.net.SASLAuthentication; import org.jivesoftware.openfire.net.SocketConnection; import org.jivesoftware.openfire.server.ServerDialback; +import org.jivesoftware.openfire.server.ServerDialbackErrorException; +import org.jivesoftware.openfire.server.ServerDialbackKeyInvalidException; import org.jivesoftware.openfire.spi.ConnectionType; import org.jivesoftware.util.CertificateManager; +import org.jivesoftware.util.StreamErrorException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.xmlpull.v1.XmlPullParser; @@ -133,7 +138,9 @@ public static LocalIncomingServerSession createSession(String serverName, XmlPul // Send the stream header StringBuilder openingStream = new StringBuilder(); openingStream.append(" remoteAuthMutex = Interners.newWeakInterner(); - /** - * Controls the S2S outgoing session initialise timeout time in seconds - */ - public static final SystemProperty INITIALISE_TIMEOUT_SECONDS = SystemProperty.Builder.ofType(Duration.class) - .setKey("xmpp.server.session.initialise-timeout") - .setDefaultValue(Duration.ofSeconds(5)) - .setChronoUnit(ChronoUnit.SECONDS) - .setDynamic(true) - .build(); - - private OutgoingServerSocketReader socketReader; + private final OutgoingServerSocketReader socketReader; private final Collection outgoingDomainPairs = new HashSet<>(); /** @@ -243,7 +248,8 @@ public static boolean authenticateDomain(final DomainPair domainPair) { * @param port default port to use to establish the connection. * @return new outgoing session to a remote domain, or null. */ - private static LocalOutgoingServerSession createOutgoingSession(@Nonnull final DomainPair domainPair, int port) { + // package-protected to facilitate unit testing.. + static LocalOutgoingServerSession createOutgoingSession(@Nonnull final DomainPair domainPair, int port) { final Logger log = LoggerFactory.getLogger( Log.getName() + "[Create outgoing session for: " + domainPair + "]" ); log.debug( "Creating new session..." ); @@ -259,21 +265,179 @@ private static LocalOutgoingServerSession createOutgoingSession(@Nonnull final D Socket socket = socketToXmppDomain.getKey(); boolean directTLS = socketToXmppDomain.getValue(); - final SocketAddress socketAddress = socket.getRemoteSocketAddress(); - log.debug( "Opening a new connection to {} {}.", socketAddress, directTLS ? "using directTLS" : "that is initially not encrypted" ); - NettySessionInitializer sessionInitialiser = null; + SocketConnection connection = null; try { + final SocketAddress socketAddress = socket.getRemoteSocketAddress(); + log.debug( "Opening a new connection to {} {}.", socketAddress, directTLS ? "using directTLS" : "that is initially not encrypted" ); + connection = new SocketConnection(XMPPServer.getInstance().getPacketDeliverer(), socket, false); + if (directTLS) { + try { + connection.startTLS( true, true ); + } catch ( SSLException ex ) { + if ( JiveGlobals.getBooleanProperty(ConnectionSettings.Server.TLS_ON_PLAIN_DETECTION_ALLOW_NONDIRECTTLS_FALLBACK, true) && ex.getMessage().contains( "plaintext connection?" ) ) { + Log.warn( "Plaintext detected on a new connection that is was started in DirectTLS mode (socket address: {}). Attempting to restart the connection in non-DirectTLS mode.", socketAddress ); + try { + // Close old socket + socket.close(); + } catch ( Exception e ) { + Log.debug( "An exception occurred (and is ignored) while trying to close a socket that was already in an error state.", e ); + } + socket = new Socket(); + socket.connect( socketAddress, RemoteServerManager.getSocketTimeout() ); + connection = new SocketConnection(XMPPServer.getInstance().getPacketDeliverer(), socket, false); + directTLS = false; + Log.info( "Re-established connection to {}. Proceeding without directTLS.", socketAddress ); + } else { + // Do not retry as non-DirectTLS, rethrow the exception. + throw ex; + } + } + } + + log.debug( "Send the stream header and wait for response..." ); + StringBuilder openingStream = new StringBuilder(); + openingStream.append(""); + connection.deliverRawText(openingStream.toString()); - // Wait for the future to give us a session... - sessionInitialiser = new NettySessionInitializer(domainPair, port, directTLS); // Set a read timeout (of 5 seconds) so we don't keep waiting forever - return (LocalOutgoingServerSession) sessionInitialiser.init().get(INITIALISE_TIMEOUT_SECONDS.getValue().getSeconds(), TimeUnit.SECONDS); + int soTimeout = socket.getSoTimeout(); + socket.setSoTimeout(5000); + + XMPPPacketReader reader = new XMPPPacketReader(); + + final InputStream inputStream; + if (directTLS) { + inputStream = connection.getTLSStreamHandler().getInputStream(); + } else { + inputStream = socket.getInputStream(); + } + reader.getXPPParser().setInput(new InputStreamReader( inputStream, StandardCharsets.UTF_8 )); + + // Get the answer from the Receiving Server + XmlPullParser xpp = reader.getXPPParser(); + for (int eventType = xpp.getEventType(); eventType != XmlPullParser.START_TAG;) { + eventType = xpp.next(); + } + + String serverVersion = xpp.getAttributeValue("", "version"); + String id = xpp.getAttributeValue("", "id"); + log.debug( "Got a response (stream ID: {}, version: {}). Check if the remote server is XMPP 1.0 compliant...", id, serverVersion ); + + if (serverVersion != null && Session.decodeVersion(serverVersion)[0] >= 1) { + log.debug( "The remote server is XMPP 1.0 compliant (or at least reports to be)." ); + + // Restore default timeout + socket.setSoTimeout(soTimeout); + + Element features = reader.parseDocument().getRootElement(); + if (features != null) { + log.debug( "Processing stream features of the remote domain: {}", features.asXML() ); + if (directTLS) { + log.debug( "We connected to the remote server using direct TLS. Authenticate the connection with SASL..." ); + LocalOutgoingServerSession answer = authenticate(domainPair, connection, reader, openingStream, features, id); + if (answer != null) { + log.debug( "Successfully authenticated the connection with SASL)!" ); + // Everything went fine so return the encrypted and authenticated connection. + log.debug( "Successfully created new session!" ); + return answer; + } + log.debug( "Unable to authenticate the connection with SASL." ); + } else { + log.debug( "Check if both us as well as the remote server have enabled STARTTLS and/or dialback ..." ); + final boolean useTLS = connection.getTlsPolicy() == Connection.TLSPolicy.optional || connection.getTlsPolicy() == Connection.TLSPolicy.required; + if (useTLS && features.element("starttls") != null) { + log.debug( "Both us and the remote server support the STARTTLS feature. Encrypt and authenticate the connection with TLS & SASL..." ); + LocalOutgoingServerSession answer = encryptAndAuthenticate(domainPair, connection, reader, openingStream); + if (answer != null) { + log.debug( "Successfully encrypted/authenticated the connection with TLS/SASL)!" ); + // Everything went fine so return the secured and + // authenticated connection + log.debug( "Successfully created new session!" ); + return answer; + } + log.debug( "Unable to encrypt and authenticate the connection with TLS & SASL." ); + } + else if (connection.getTlsPolicy() == Connection.TLSPolicy.required) { + log.debug("I have no StartTLS yet I must TLS"); + connection.close(new StreamError(StreamError.Condition.not_authorized, "TLS is mandatory, but was not established.")); + return null; + } + // Check if we are going to try server dialback (XMPP 1.0) + else if (ServerDialback.isEnabled() && features.element("dialback") != null) { + log.debug( "Both us and the remote server support the 'dialback' feature. Authenticate the connection with dialback..." ); + ServerDialback method = new ServerDialback(connection, domainPair); + OutgoingServerSocketReader newSocketReader = new OutgoingServerSocketReader(reader); + if (method.authenticateDomain(newSocketReader, id)) { + log.debug( "Successfully authenticated the connection with dialback!" ); + StreamID streamID = BasicStreamIDFactory.createStreamID(id); + LocalOutgoingServerSession session = new LocalOutgoingServerSession(domainPair.getLocal(), connection, newSocketReader, streamID); + connection.init(session); + session.setAuthenticationMethod(AuthenticationMethod.DIALBACK); + // Set the remote domain name as the address of the session. + session.setAddress(new JID(null, domainPair.getRemote(), null)); + log.debug( "Successfully created new session!" ); + return session; + } + else { + log.debug( "Unable to authenticate the connection with dialback." ); + } + } + } + } + else { + log.debug( "Error! No data from the remote server (expected a 'feature' element)."); + } + } else { + log.debug( "The remote server is not XMPP 1.0 compliant." ); + } + + log.debug( "Something went wrong so close the connection and try server dialback over a plain connection" ); + if (connection.getTlsPolicy() == Connection.TLSPolicy.required) { + log.debug("I have no StartTLS yet I must TLS"); + connection.close(new StreamError(StreamError.Condition.not_authorized, "TLS is mandatory, but was not established.")); + return null; + } + connection.close(); + } + catch (SSLHandshakeException e) + { + // When not doing direct TLS but startTLS, this a failure as described in RFC6120, section 5.4.3.2 "STARTTLS Failure". + log.info( "{} negotiation failed. Closing connection (without sending any data such as or ).", (directTLS ? "Direct TLS" : "StartTLS" ), e ); + + // The receiving entity is expected to close the socket *without* sending any more data ( nor ). + // It is probably (see OF-794) best if we, as the initiating entity, therefor don't send any data either. + if (connection != null) { + connection.forceClose(); + + if (connection.getTlsPolicy() == Connection.TLSPolicy.required) { + return null; + } + } + + if (e.getCause() instanceof CertificateException && JiveGlobals.getBooleanProperty(ConnectionSettings.Server.STRICT_CERTIFICATE_VALIDATION, true)) { + log.warn("Aborting attempt to create outgoing session as TLS handshake failed, and strictCertificateValidation is enabled.", e); + return null; + } } catch (Exception e) { - // This might be RFC3620, section 5.4.2.2 "Failure Case" or even an unrelated problem. Handle 'normally'. + // This might be RFC6120, section 5.4.2.2 "Failure Case" or even an unrelated problem. Handle 'normally'. log.warn( "An exception occurred while creating an encrypted session. Closing connection.", e ); - if (sessionInitialiser != null) { sessionInitialiser.stop(); } + + if (connection != null) { + connection.close(); + if (connection.getTlsPolicy() == Connection.TLSPolicy.required) { + return null; + } + } } if (ServerDialback.isEnabled()) @@ -297,16 +461,219 @@ private static LocalOutgoingServerSession createOutgoingSession(@Nonnull final D } } + private static LocalOutgoingServerSession encryptAndAuthenticate(DomainPair domainPair, SocketConnection connection, XMPPPacketReader reader, StringBuilder openingStream) throws Exception { + final Logger log = LoggerFactory.getLogger(Log.getName() + "[Encrypt connection for: " + domainPair + "]" ); + Element features; + + log.debug( "Encrypting and authenticating connection ..."); + + log.debug( "Indicating we want TLS and wait for response." ); + connection.deliverRawText( "" ); + + MXParser xpp = reader.getXPPParser(); + // Wait for the response + Element proceed = reader.parseDocument().getRootElement(); + if (proceed != null && proceed.getName().equals("proceed")) { + log.debug( "Received 'proceed' from remote server. Negotiating TLS..." ); + try { +// boolean needed = JiveGlobals.getBooleanProperty(ConnectionSettings.Server.TLS_CERTIFICATE_VERIFY, true) && +// JiveGlobals.getBooleanProperty(ConnectionSettings.Server.TLS_CERTIFICATE_CHAIN_VERIFY, true) && +// !JiveGlobals.getBooleanProperty(ConnectionSettings.Server.TLS_ACCEPT_SELFSIGNED_CERTS, false); + connection.startTLS(true, false); + } catch(Exception e) { + log.debug("TLS negotiation failed: " + e.getMessage()); + throw e; + } + log.debug( "TLS negotiation was successful. Connection encrypted. Proceeding with authentication..." ); + + // If TLS cannot be used for authentication, it is permissible to use another authentication mechanism + // such as dialback. RFC 6120 does not explicitly allow this, as it does not take into account any other + // authentication mechanism other than TLS (it does mention dialback in an interoperability note. However, + // RFC 7590 Section 3.4 writes: "In particular for XMPP server-to-server interactions, it can be reasonable + // for XMPP server implementations to accept encrypted but unauthenticated connections when Server Dialback + // keys [XEP-0220] are used." In short: if Dialback is allowed, unauthenticated TLS is better than no TLS. + if (!SASLAuthentication.verifyCertificates(connection.getPeerCertificates(), domainPair.getRemote(), true)) { + if (JiveGlobals.getBooleanProperty(ConnectionSettings.Server.STRICT_CERTIFICATE_VALIDATION, true)) { + log.warn("Aborting attempt to create outgoing session as TLS handshake failed, and strictCertificateValidation is enabled."); + return null; + } + if (ServerDialback.isEnabled() || ServerDialback.isEnabledForSelfSigned()) { + log.debug( "SASL authentication failed. Will continue with dialback." ); + } else { + log.warn( "Unable to authenticate the connection: SASL authentication failed (and dialback is not available)." ); + return null; + } + } + + log.debug( "TLS negotiation was successful so initiate a new stream." ); + connection.deliverRawText( openingStream.toString() ); + + // Reset the parser to use the new secured reader + xpp.setInput(new InputStreamReader(connection.getTLSStreamHandler().getInputStream(), StandardCharsets.UTF_8)); + // Skip new stream element + for (int eventType = xpp.getEventType(); eventType != XmlPullParser.START_TAG;) { + eventType = xpp.next(); + } + // Get the stream ID + String id = xpp.getAttributeValue("", "id"); + // Get new stream features + features = reader.parseDocument().getRootElement(); + if (features != null) { + return authenticate( domainPair, connection, reader, openingStream, features, id ); + } + else { + log.debug( "Failed to encrypt and authenticate connection: neither SASL mechanisms nor SERVER DIALBACK were offered by the remote host." ); + return null; + } + } + else { + log.debug( "Failed to encrypt and authenticate connection: was not received!" ); + return null; + } + } + + private static LocalOutgoingServerSession authenticate( final DomainPair domainPair, + final SocketConnection connection, + final XMPPPacketReader reader, + final StringBuilder openingStream, + final Element features, + final String id ) throws DocumentException, IOException, XmlPullParserException + { + final Logger log = LoggerFactory.getLogger(Log.getName() + "[Authenticate connection for: " + domainPair + "]" ); + + MXParser xpp = reader.getXPPParser(); + + // Bookkeeping: determine what functionality the remote server offers. + boolean saslEXTERNALoffered = false; + if (features.element("mechanisms") != null) { + Iterator it = features.element( "mechanisms").elementIterator(); + while (it.hasNext()) { + Element mechanism = it.next(); + if ("EXTERNAL".equals(mechanism.getTextTrim())) { + saslEXTERNALoffered = true; + break; + } + } + } + final boolean dialbackOffered = features.element("dialback") != null; + + log.debug("Remote server is offering dialback: {}, EXTERNAL SASL: {}", dialbackOffered, saslEXTERNALoffered ); + + LocalOutgoingServerSession result = null; + + // first, try SASL + if (saslEXTERNALoffered) { + log.debug( "Trying to authenticate with EXTERNAL SASL." ); + result = attemptSASLexternal(connection, xpp, reader, domainPair, id, openingStream); + if (result == null) { + log.debug( "Failed to authenticate with EXTERNAL SASL." ); + } else { + log.debug( "Successfully authenticated with EXTERNAL SASL." ); + } + } + + // SASL unavailable or failed, try dialback. + if (result == null) { + log.debug( "Trying to authenticate with dialback." ); + result = attemptDialbackOverTLS(connection, reader, domainPair, id); + if (result == null) { + log.debug( "Failed to authenticate with dialback." ); + } else { + log.debug( "Successfully authenticated with dialback." ); + } + } + + if ( result != null ) { + log.debug( "Successfully encrypted and authenticated connection!" ); + return result; + } else { + log.warn( "Unable to encrypt and authenticate connection: Exhausted all options." ); + return null; + } + } + + private static LocalOutgoingServerSession attemptDialbackOverTLS(Connection connection, XMPPPacketReader reader, DomainPair domainPair, String id) { + final Logger log = LoggerFactory.getLogger( Log.getName() + "[Dialback over TLS for: " + domainPair + " (Stream ID: " + id + ")]" ); + + if (ServerDialback.isEnabled() || ServerDialback.isEnabledForSelfSigned()) { + log.debug("Trying to connecting using dialback over TLS."); + ServerDialback method = new ServerDialback(connection, domainPair); + OutgoingServerSocketReader newSocketReader = new OutgoingServerSocketReader(reader); + if (method.authenticateDomain(newSocketReader, id)) { + log.debug("Dialback over TLS was successful."); + StreamID streamID = BasicStreamIDFactory.createStreamID(id); + LocalOutgoingServerSession session = new LocalOutgoingServerSession(domainPair.getLocal(), connection, newSocketReader, streamID); + connection.init(session); + // Set the remote domain name as the address of the session. + session.setAddress(new JID(null, domainPair.getRemote(), null)); + session.setAuthenticationMethod(AuthenticationMethod.DIALBACK); + return session; + } + else { + log.debug("Dialback over TLS failed"); + return null; + } + } + else { + log.debug("Skipping server dialback attempt as it has been disabled by local configuration."); + return null; + } + } + + private static LocalOutgoingServerSession attemptSASLexternal(SocketConnection connection, MXParser xpp, XMPPPacketReader reader, DomainPair domainPair, String id, StringBuilder openingStream) throws DocumentException, IOException, XmlPullParserException { + final Logger log = LoggerFactory.getLogger( Log.getName() + "[EXTERNAL SASL for: " + domainPair + " (Stream ID: " + id + ")]" ); + + log.debug("Starting EXTERNAL SASL."); + if (doExternalAuthentication(domainPair.getLocal(), connection, reader)) { + log.debug("EXTERNAL SASL was successful."); + // SASL was successful so initiate a new stream + connection.deliverRawText(openingStream.toString()); + + // Reset the parser + //xpp.resetInput(); + // // Reset the parser to use the new secured reader + xpp.setInput(new InputStreamReader(connection.getTLSStreamHandler().getInputStream(), StandardCharsets.UTF_8)); + // Skip the opening stream sent by the server + for (int eventType = xpp.getEventType(); eventType != XmlPullParser.START_TAG;) { + eventType = xpp.next(); + } + + // SASL authentication was successful so create new OutgoingServerSession + id = xpp.getAttributeValue("", "id"); + StreamID streamID = BasicStreamIDFactory.createStreamID(id); + LocalOutgoingServerSession session = new LocalOutgoingServerSession(domainPair.getLocal(), connection, new OutgoingServerSocketReader(reader), streamID); + connection.init(session); + // Set the remote domain name as the address of the session + session.setAddress(new JID(null, domainPair.getRemote(), null)); + // Set that the session was created using TLS+SASL (no server dialback) + session.setAuthenticationMethod(AuthenticationMethod.SASL_EXTERNAL); + return session; + } + else { + log.debug("EXTERNAL SASL failed."); + return null; + } + } + + private static boolean doExternalAuthentication(String localDomain, SocketConnection connection, + XMPPPacketReader reader) throws DocumentException, IOException, XmlPullParserException { + + StringBuilder sb = new StringBuilder(); + sb.append(""); + sb.append(StringUtils.encodeBase64(localDomain)); + sb.append(""); + connection.deliverRawText(sb.toString()); + + Element response = reader.parseDocument().getRootElement(); + return response != null && "success".equals(response.getName()); + } + public LocalOutgoingServerSession(String localDomain, Connection connection, OutgoingServerSocketReader socketReader, StreamID streamID) { super(localDomain, connection, streamID); this.socketReader = socketReader; socketReader.setSession(this); } - public LocalOutgoingServerSession(String localDomain, Connection connection, StreamID streamID) { - super(localDomain, connection, streamID); - } - @Override boolean canProcess(Packet packet) { final DomainPair domainPair = new DomainPair(packet.getFrom().getDomain(), packet.getTo().getDomain()); diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/spi/ConnectionConfiguration.java b/xmppserver/src/main/java/org/jivesoftware/openfire/spi/ConnectionConfiguration.java index 53764c10d2..a5b9dde028 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/spi/ConnectionConfiguration.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/spi/ConnectionConfiguration.java @@ -28,6 +28,7 @@ public class ConnectionConfiguration private final CertificateStoreConfiguration trustStoreConfiguration; private final boolean acceptSelfSignedCertificates; private final boolean verifyCertificateValidity; + private final boolean strictCertificateValidation; private final Set encryptionProtocols; private final Set encryptionCipherSuites; private final Connection.CompressionPolicy compressionPolicy; @@ -52,9 +53,10 @@ public class ConnectionConfiguration * @param encryptionProtocols the set of protocols supported * @param encryptionCipherSuites the set of ciphers supported * @param compressionPolicy the compression policy + * @param strictCertificateValidation {@code true} to abort connections if certificate validation fails, otherwise {@code false} */ // TODO input validation - public ConnectionConfiguration( ConnectionType type, boolean enabled, int maxThreadPoolSize, int maxBufferSize, Connection.ClientAuth clientAuth, InetAddress bindAddress, int port, Connection.TLSPolicy tlsPolicy, CertificateStoreConfiguration identityStoreConfiguration, CertificateStoreConfiguration trustStoreConfiguration, boolean acceptSelfSignedCertificates, boolean verifyCertificateValidity, Set encryptionProtocols, Set encryptionCipherSuites, Connection.CompressionPolicy compressionPolicy ) + public ConnectionConfiguration( ConnectionType type, boolean enabled, int maxThreadPoolSize, int maxBufferSize, Connection.ClientAuth clientAuth, InetAddress bindAddress, int port, Connection.TLSPolicy tlsPolicy, CertificateStoreConfiguration identityStoreConfiguration, CertificateStoreConfiguration trustStoreConfiguration, boolean acceptSelfSignedCertificates, boolean verifyCertificateValidity, Set encryptionProtocols, Set encryptionCipherSuites, Connection.CompressionPolicy compressionPolicy, boolean strictCertificateValidation ) { if ( maxThreadPoolSize <= 0 ) { throw new IllegalArgumentException( "Argument 'maxThreadPoolSize' must be equal to or greater than one." ); @@ -78,6 +80,7 @@ public ConnectionConfiguration( ConnectionType type, boolean enabled, int maxThr this.encryptionProtocols = Collections.unmodifiableSet( encryptionProtocols ); this.encryptionCipherSuites = Collections.unmodifiableSet( encryptionCipherSuites ); this.compressionPolicy = compressionPolicy; + this.strictCertificateValidation = strictCertificateValidation; final CertificateStoreManager certificateStoreManager = XMPPServer.getInstance().getCertificateStoreManager(); this.identityStore = certificateStoreManager.getIdentityStore( type ); @@ -201,4 +204,14 @@ public boolean isEnabled() { return enabled; } + + /** + * A boolean that indicates if the connection should be aborted if certificate validation fails. + * When true Openfire strictly follows RFC 6120, section 13.7.2 + * + * @return true when connections are aborted if certificate validation fails, otherwise false. + */ + public boolean isStrictCertificateValidation() { + return strictCertificateValidation; + } } diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/spi/ConnectionListener.java b/xmppserver/src/main/java/org/jivesoftware/openfire/spi/ConnectionListener.java index 89b056cc26..613291fb56 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/spi/ConnectionListener.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/spi/ConnectionListener.java @@ -281,7 +281,8 @@ public ConnectionConfiguration generateConnectionConfiguration() verifyCertificateValidity(), getEncryptionProtocols(), getEncryptionCipherSuites(), - getCompressionPolicy() + getCompressionPolicy(), + getStrictCertificateValidation() ); } @@ -1015,6 +1016,46 @@ public void setEncryptionCipherSuites( String[] cipherSuites ) restart(); } + /** + * Defines whether a connection should be aborted if certificate validation fails. + * When set to true Openfire strictly follows RFC 6120, section 13.7.2. + * + * @param strictCertificateValidation true to abort connections if certificate validation fails, otherwise false + */ + public void setStrictCertificateValidation(boolean strictCertificateValidation) { + final boolean oldValue = getStrictCertificateValidation(); + + // Always set the property explicitly even if it appears the equal to the old value (the old value might be a fallback value). + JiveGlobals.setProperty( type.getPrefix() + "certificate.strict-validation", Boolean.toString( strictCertificateValidation ) ); + + if ( oldValue == strictCertificateValidation ) { + Log.debug( "Ignoring strict certificate validation configuration change request (to '{}'): listener already in this state.", strictCertificateValidation ); + return; + } + + Log.debug( "Changing strict certificate validation configuration from '{}' to '{}'.", oldValue, strictCertificateValidation ); + restart(); + } + + /** + * A boolean that indicates if the connection should be aborted if certificate validation fails. + * When true Openfire strictly follows RFC 6120, section 13.7.2. If not previously set, value of true is + * returned by default. + * + * @return true when connections are aborted if certificate validation fails, otherwise false. + */ + private boolean getStrictCertificateValidation() { + final String propertyName = type.getPrefix() + "certificate.strict-validation"; + final boolean defaultValue = true; + + if ( type.getFallback() == null ) { + return JiveGlobals.getBooleanProperty( propertyName, defaultValue ); + } + else { + return JiveGlobals.getBooleanProperty( propertyName, getConnectionListener( type.getFallback() ).getStrictCertificateValidation() ); + } + } + @Override public String toString() { @@ -1024,4 +1065,6 @@ public String toString() '}'; } + } + diff --git a/xmppserver/src/main/java/org/jivesoftware/util/StreamErrorException.java b/xmppserver/src/main/java/org/jivesoftware/util/StreamErrorException.java index b1b2fb3d0c..095122e756 100644 --- a/xmppserver/src/main/java/org/jivesoftware/util/StreamErrorException.java +++ b/xmppserver/src/main/java/org/jivesoftware/util/StreamErrorException.java @@ -45,7 +45,7 @@ public StreamErrorException(StreamError.Condition condition, String message, Thr public StreamErrorException(StreamError.Condition condition, Throwable cause) { - super(cause); + super(condition.name(), cause); this.streamError = new StreamError(condition); } @@ -57,6 +57,7 @@ public StreamErrorException(StreamError.Condition condition, String message, Thr public StreamErrorException(StreamError streamError) { + super(streamError != null && streamError.getText() != null && !streamError.getText().isEmpty() ? streamError.getText() : null); this.streamError = streamError; } @@ -74,7 +75,7 @@ public StreamErrorException(StreamError streamError, String message, Throwable c public StreamErrorException(StreamError streamError, Throwable cause) { - super(cause); + super(streamError != null && streamError.getText() != null && !streamError.getText().isEmpty() ? streamError.getText() : null, cause); this.streamError = streamError; } diff --git a/xmppserver/src/main/webapp/connection-settings-advanced.jsp b/xmppserver/src/main/webapp/connection-settings-advanced.jsp index 395e8a7d57..f511cf4616 100644 --- a/xmppserver/src/main/webapp/connection-settings-advanced.jsp +++ b/xmppserver/src/main/webapp/connection-settings-advanced.jsp @@ -89,6 +89,8 @@ final int listenerMaxThreads = ParamUtils.getIntParameter( request, "maxThreads", configuration.getMaxThreadPoolSize() ); final boolean acceptSelfSignedCertificates = ParamUtils.getBooleanParameter( request, "accept-self-signed-certificates" ); final boolean verifyCertificateValidity = ParamUtils.getBooleanParameter( request, "verify-certificate-validity" ); + final boolean strictCertificateValidation = ParamUtils.getBooleanParameter( request, "strict-certificate-validation" ); + // Apply new configuration @@ -104,6 +106,7 @@ listener.setEncryptionCipherSuites( cipherSuites ); listener.setAcceptSelfSignedCertificates( acceptSelfSignedCertificates ); listener.setVerifyCertificateValidity( verifyCertificateValidity ); + listener.setStrictCertificateValidation( strictCertificateValidation ); // Log the event webManager.logEvent( "Updated connection settings for " + connectionType + " (mode: " + connectionModeParam + ")", configuration.toString() ); @@ -367,6 +370,14 @@ + + + + + + + + diff --git a/xmppserver/src/test/java/org/jivesoftware/Fixtures.java b/xmppserver/src/test/java/org/jivesoftware/Fixtures.java index c9454188fc..f133563a10 100644 --- a/xmppserver/src/test/java/org/jivesoftware/Fixtures.java +++ b/xmppserver/src/test/java/org/jivesoftware/Fixtures.java @@ -1,11 +1,25 @@ +/* + * Copyright (C) 2022-2023 Ignite Realtime Foundation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.jivesoftware; import org.dom4j.Document; import org.dom4j.DocumentException; import org.dom4j.io.SAXReader; -import org.jivesoftware.openfire.IQRouter; -import org.jivesoftware.openfire.XMPPServer; -import org.jivesoftware.openfire.XMPPServerInfo; +import org.jivesoftware.openfire.*; +import org.jivesoftware.openfire.spi.*; import org.jivesoftware.openfire.user.User; import org.jivesoftware.openfire.user.UserAlreadyExistsException; import org.jivesoftware.openfire.user.UserNotFoundException; @@ -28,6 +42,7 @@ public final class Fixtures { public static final String XMPP_DOMAIN = "test.xmpp.domain"; + public static final String HOSTNAME = XMPP_DOMAIN; // Make hostname be XMPP Domain name, to avoid the need for DNS SRV lookups private Fixtures() { } @@ -79,6 +94,8 @@ public static XMPPServer mockXMPPServer() { .when(xmppServer).createJID(any(String.class), nullable(String.class), any(Boolean.class)); doReturn(mockXMPPServerInfo()).when(xmppServer).getServerInfo(); doReturn(mockIQRouter()).when(xmppServer).getIQRouter(); + doReturn(mockConnectionManager()).when(xmppServer).getConnectionManager(); + doReturn(mockSessionManager()).when(xmppServer).getSessionManager(); return xmppServer; } @@ -86,6 +103,7 @@ public static XMPPServer mockXMPPServer() { public static XMPPServerInfo mockXMPPServerInfo() { final XMPPServerInfo xmppServerInfo = mock(XMPPServerInfo.class, withSettings().lenient()); doReturn(XMPP_DOMAIN).when(xmppServerInfo).getXMPPDomain(); + doReturn(HOSTNAME).when(xmppServerInfo).getHostname(); return xmppServerInfo; } @@ -94,6 +112,30 @@ public static IQRouter mockIQRouter() { return iqRouter; } + public static SessionManager mockSessionManager() { + final SessionManager sessionManager = mock(SessionManager.class, withSettings().lenient()); + when(sessionManager.nextStreamID()).thenReturn(new BasicStreamIDFactory().createStreamID()); + return sessionManager; + } + + public static ConnectionManager mockConnectionManager() { + final ConnectionManager connectionManager = mock(ConnectionManagerImpl.class, withSettings().lenient()); + doReturn(mockConnectionListener()).when(connectionManager).getListener(any(ConnectionType.class), anyBoolean()); + return connectionManager; + } + + public static ConnectionListener mockConnectionListener() { + final ConnectionListener connectionListener = mock(ConnectionListener.class, withSettings().lenient()); + doReturn(mockConnectionConfiguration()).when(connectionListener).generateConnectionConfiguration(); + return connectionListener; + } + + public static ConnectionConfiguration mockConnectionConfiguration() { + final ConnectionConfiguration connectionListener = mock(ConnectionConfiguration.class, withSettings().lenient()); + doReturn(Connection.TLSPolicy.optional).when(connectionListener).getTlsPolicy(); + return connectionListener; + } + public static class StubUserProvider implements UserProvider { final Map users = new HashMap<>(); @@ -207,4 +249,58 @@ public static IQ iqFrom(final String stanza) { } } + /** + * Self-Signed expired X509 certificate for testing purposes (expired 17th June 2023) + */ + public static final String expiredX509Certificate + = "-----BEGIN CERTIFICATE-----\n" + + "MIICsjCCAZoCCQDsFzeWUN/PbjANBgkqhkiG9w0BAQsFADAbMRkwFwYDVQQDDBB0\n" + + "ZXN0LnhtcHAuZG9tYWluMB4XDTIzMDYxNjEzMzE0OFoXDTIzMDYxNzEzMzE0OFow\n" + + "GzEZMBcGA1UEAwwQdGVzdC54bXBwLmRvbWFpbjCCASIwDQYJKoZIhvcNAQEBBQAD\n" + + "ggEPADCCAQoCggEBAKxaCt4vSjqzXwCfui+S0KjnQrxVagDKJOHbyhkxTbRROJYz\n" + + "7SmdAUVHCcFlugOk7UhxTBZ7hHeC3DQTvqilwISRsgyOM8MSAXKV6lvWu2WDRI7s\n" + + "LRg5o6r23Me/kiSMXGpzaWitxMOgZWxYJlLb7CfIJwN16F6UvKsX6npN5ETnvzkV\n" + + "PZNgoWvy/TX2QlPJTiukX0a4FbyX6REkAgtI6WfLJm7lqtJZFw7KoX2g8GO+wk3v\n" + + "akeuuA8OIrtRg5eP5K82a//sF1VoCh9vOryr4mT2BTa8L6x+bF27WMc8+QzXf0JL\n" + + "s+Iy8J5dneQWEZPK13Eh5doE59EBx33fadT90CsCAwEAATANBgkqhkiG9w0BAQsF\n" + + "AAOCAQEAQn05d+0QjKH5osqz7ZKm7wle/pF1KkOmCD9lfU1+Iu02Adz2nUx+WTaH\n" + + "dtB8MtkLQFMcqz6oYquD0ruE3KUvj/A5fmir8wz0m9MG/i3QNrytWepv4vlrcmMr\n" + + "yfZLwSrR3UNcNYk9W01/xjQVgH2JsF1B1nbn7eJt0mzr0arHp7VjtjdDJIfkZEEh\n" + + "5ZpOERffIINoEptoKMCfjbcp2+PLZ1TjL6MxtrVs+TQmX4i9wL03uNgItbFqYeYP\n" + + "RVeb9OaNj4NRMZB49D/jIaqmQWZi6u2ooOQv6AlyzeKInWm+aDmxCAX4IZAod18/\n" + + "1hI2qIN8Xj9GUT7wldL368Dq1fUI5g==\n" + + "-----END CERTIFICATE-----\n"; + + /** + * Private key for {@link #expiredX509Certificate} for testing purposes + */ + public static final String privateKeyForExpiredCert + = "-----BEGIN PRIVATE KEY-----\n" + + "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCsWgreL0o6s18A\n" + + "n7ovktCo50K8VWoAyiTh28oZMU20UTiWM+0pnQFFRwnBZboDpO1IcUwWe4R3gtw0\n" + + "E76opcCEkbIMjjPDEgFylepb1rtlg0SO7C0YOaOq9tzHv5IkjFxqc2lorcTDoGVs\n" + + "WCZS2+wnyCcDdehelLyrF+p6TeRE5785FT2TYKFr8v019kJTyU4rpF9GuBW8l+kR\n" + + "JAILSOlnyyZu5arSWRcOyqF9oPBjvsJN72pHrrgPDiK7UYOXj+SvNmv/7BdVaAof\n" + + "bzq8q+Jk9gU2vC+sfmxdu1jHPPkM139CS7PiMvCeXZ3kFhGTytdxIeXaBOfRAcd9\n" + + "32nU/dArAgMBAAECggEAfE6lMAMjoprklmqdutpFLM0/UN66Cb/CQjRn2yu4Q6mh\n" + + "CpSBJVZcKD9IRfi85Qv7KBivLDgCHsB/WgAzryd9ZyA+LtgRdUxzRtXhpkOF/X1j\n" + + "4UFudN59sT1Dl74QBdRGg3CiQiGynPX+sNoTKgf8l+TAXrqX0j+spConr/amARuF\n" + + "xpvg+OSmZXCwzzLQbUEsigEoeF6ePBLjhbwFwtwbyuMqivLEZWGp/CudhNWIsuwq\n" + + "aCUQ9Pz8VtcfJOabvRUykeLxJBglhjhIASYqR1OysLsoqSLzEC1cBaWqD3aRr4xL\n" + + "ZGlgnAHOuU9DwO5qLdrGpLRd/acCzNkR0ojoqLFIAQKBgQDUcOS0dy5kWIpz6ECY\n" + + "HIJyipjDGcy6VtTXmh0uKDmxJPWCAwPl6xYmF/pLQDzCw2knHrF/bSx0uTw7jUh/\n" + + "RGnHcqorcUyH+J3JVuoMyoWMuI7o7lIygepJXNqd9dNiGgYvhumCM4Y6YQJ7AsFE\n" + + "FilHBNBteMDl4OM3dG7S60ldAQKBgQDPsNlMUU6OkDfH4/4nWFk9nlQEt1r52lmW\n" + + "/yRmwFKJRGDYcjjndMeBQZEMTJUsWH4s0/QwmXxMXrR5FbKIK/nQVdfG8TrAAZ6R\n" + + "Jdt9o3pmjBINHd5dgnHBXtiK3sD/GJwtFn83yLOK9drhoMiI94yc1tgS6vn1ZTvs\n" + + "lhvunt4xKwKBgDYWoERKa+dkm6uzIG8aIyRioU5bTULMRNi4BmHwH/A4RsHZXq61\n" + + "UihUxodOTaoQ8r7hE7Qr6bu2Rd2rtR+iHYSIb0csS5368MGIfYLQNXyEqO4pb4go\n" + + "h6wyFf9NzYoWsih7owxhbfWDKYyEQQzCz7OjSCX3LrXYskE2RdkxyrYBAoGBAMwY\n" + + "24G/CPbaTKa3q2PY02HVPHWiBdowtAfJ1WjQKIvSUWWC4d66iO/BkhvHCnUYxW2i\n" + + "IH695kNacfnn05kztfwAz9ol5vkW3k9/J3IQ+9DYZ0jSiFnWPZmsbhoSCxDki11X\n" + + "lU8pgR7WufEuQsMumdTq4E2+8kIv6LJ3VR2qq2kfAoGBAM1bz/DiXYawNWaE1/tW\n" + + "2ljpPehXVrTe7IZDPk/L0NnMn+NfxxzHLRtqqrHNGFPwOky/Xncg6yBS46SjyTZR\n" + + "A7+RhRBDDhV5yY7y/0FYJLKtF0K88s976Z58/3pzD6UIPP2AX3PaQS5aToGGh55Z\n" + + "7IkCfxQK6EycXiKlfAXQOPdy\n" + + "-----END PRIVATE KEY-----"; } diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/keystore/CertificateUtilsTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/keystore/CertificateUtilsTest.java index 4d6aa7baf7..31e8e3e12d 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/keystore/CertificateUtilsTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/keystore/CertificateUtilsTest.java @@ -73,7 +73,7 @@ public void testFilterValidEmpty() throws Exception public void testFilterValidWithOneValidCert() throws Exception { // Setup fixture. - final X509Certificate valid = KeystoreTestUtils.generateValidCertificate(); + final X509Certificate valid = KeystoreTestUtils.generateValidCertificate().getCertificate(); final Collection input = new ArrayList<>(); input.add( valid ); @@ -93,7 +93,7 @@ public void testFilterValidWithOneValidCert() throws Exception public void testFilterValidWithOneInvalidCert() throws Exception { // Setup fixture. - final X509Certificate invalid = KeystoreTestUtils.generateExpiredCertificate(); + final X509Certificate invalid = KeystoreTestUtils.generateExpiredCertificate().getCertificate(); final Collection input = new ArrayList<>(); input.add( invalid ); @@ -112,7 +112,7 @@ public void testFilterValidWithOneInvalidCert() throws Exception public void testFilterValidWithTwoDuplicateValidCerts() throws Exception { // Setup fixture. - final X509Certificate valid = KeystoreTestUtils.generateValidCertificate(); + final X509Certificate valid = KeystoreTestUtils.generateValidCertificate().getCertificate(); final Collection input = new ArrayList<>(); input.add( valid ); input.add( valid ); @@ -133,8 +133,8 @@ public void testFilterValidWithTwoDuplicateValidCerts() throws Exception public void testFilterValidWithTwoDistinctValidCerts() throws Exception { // Setup fixture. - final X509Certificate validA = KeystoreTestUtils.generateValidCertificate(); - final X509Certificate validB = KeystoreTestUtils.generateValidCertificate(); + final X509Certificate validA = KeystoreTestUtils.generateValidCertificate().getCertificate(); + final X509Certificate validB = KeystoreTestUtils.generateValidCertificate().getCertificate(); final Collection input = new ArrayList<>(); input.add( validA ); input.add( validB ); @@ -156,7 +156,7 @@ public void testFilterValidWithTwoDistinctValidCerts() throws Exception public void testFilterValidWithTwoDuplicateInvalidCerts() throws Exception { // Setup fixture. - final X509Certificate invalid = KeystoreTestUtils.generateExpiredCertificate(); + final X509Certificate invalid = KeystoreTestUtils.generateExpiredCertificate().getCertificate(); final Collection input = new ArrayList<>(); input.add( invalid ); input.add( invalid ); @@ -176,8 +176,8 @@ public void testFilterValidWithTwoDuplicateInvalidCerts() throws Exception public void testFilterValidWithTwoDistinctInvalidCerts() throws Exception { // Setup fixture. - final X509Certificate invalidA = KeystoreTestUtils.generateExpiredCertificate(); - final X509Certificate invalidB = KeystoreTestUtils.generateExpiredCertificate(); + final X509Certificate invalidA = KeystoreTestUtils.generateExpiredCertificate().getCertificate(); + final X509Certificate invalidB = KeystoreTestUtils.generateExpiredCertificate().getCertificate(); final Collection input = new ArrayList<>(); input.add( invalidA ); input.add( invalidB ); @@ -197,8 +197,8 @@ public void testFilterValidWithTwoDistinctInvalidCerts() throws Exception public void testFilterValidWithValidAndInvalidCerts() throws Exception { // Setup fixture. - final X509Certificate valid = KeystoreTestUtils.generateValidCertificate(); - final X509Certificate invalid = KeystoreTestUtils.generateExpiredCertificate(); + final X509Certificate valid = KeystoreTestUtils.generateValidCertificate().getCertificate(); + final X509Certificate invalid = KeystoreTestUtils.generateExpiredCertificate().getCertificate(); final Collection input = new ArrayList<>(); input.add( valid ); input.add( invalid ); @@ -224,9 +224,9 @@ public void testFilterValidWithValidAndInvalidCerts() throws Exception public void testFilterValidWithMixOfValidityAndDuplicates() throws Exception { // Setup fixture. - final X509Certificate validA = KeystoreTestUtils.generateValidCertificate(); - final X509Certificate validB = KeystoreTestUtils.generateValidCertificate(); - final X509Certificate invalid = KeystoreTestUtils.generateExpiredCertificate(); + final X509Certificate validA = KeystoreTestUtils.generateValidCertificate().getCertificate(); + final X509Certificate validB = KeystoreTestUtils.generateValidCertificate().getCertificate(); + final X509Certificate invalid = KeystoreTestUtils.generateExpiredCertificate().getCertificate(); final Collection input = new ArrayList<>(); input.add( validA ); input.add( validA ); diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/keystore/CheckChainTrustedTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/keystore/CheckChainTrustedTest.java index 176dd63065..55107851cc 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/keystore/CheckChainTrustedTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/keystore/CheckChainTrustedTest.java @@ -94,15 +94,15 @@ public void createFixture() throws Exception trustStore.load( null, null ); // Generate a valid chain and add its root certificate to the trust store. - validChain = KeystoreTestUtils.generateValidCertificateChain(); + validChain = KeystoreTestUtils.generateValidCertificateChain().getCertificateChain(); trustStore.setCertificateEntry( getLast( validChain ).getSubjectDN().getName(), getLast( validChain ) ); // Generate a chain with an expired intermediate certificate and add its root certificate to the trust store. - expiredIntChain = KeystoreTestUtils.generateCertificateChainWithExpiredIntermediateCert(); + expiredIntChain = KeystoreTestUtils.generateCertificateChainWithExpiredIntermediateCert().getCertificateChain(); trustStore.setCertificateEntry( getLast( expiredIntChain ).getSubjectDN().getName(), getLast( expiredIntChain ) ); // Generate a chain with an expired root certificate and add its root certificate to the trust store. - expiredRootChain = KeystoreTestUtils.generateCertificateChainWithExpiredRootCert(); + expiredRootChain = KeystoreTestUtils.generateCertificateChainWithExpiredRootCert().getCertificateChain(); trustStore.setCertificateEntry( getLast( expiredRootChain ).getSubjectDN().getName(), getLast( expiredRootChain ) ); // Reset the system under test before each test. @@ -321,7 +321,7 @@ public void testFullChainUnrecognizedRoot() throws Exception // Setup fixture. final X509CertSelector selector = new X509CertSelector(); selector.setSubject( validChain[0].getSubjectX500Principal() ); - final X509Certificate[] chain = KeystoreTestUtils.generateValidCertificateChain(); + final X509Certificate[] chain = KeystoreTestUtils.generateValidCertificateChain().getCertificateChain(); // Execute system under test. trustManager.checkChainTrusted( selector, chain ); @@ -445,7 +445,7 @@ public void testExpiredRootChainPartial() throws Exception public void testSelfSigned() throws Exception { // Setup fixture. - final X509Certificate[] chain = new X509Certificate[] { KeystoreTestUtils.generateSelfSignedCertificate() }; + final X509Certificate[] chain = new X509Certificate[] { KeystoreTestUtils.generateSelfSignedCertificate().getCertificate() }; final X509CertSelector selector = new X509CertSelector(); selector.setSubject( chain[ 0 ].getSubjectX500Principal() ); @@ -481,7 +481,7 @@ public void testSelfSigned() throws Exception public void testSelfSignedExpired() throws Exception { // Setup fixture. - final X509Certificate[] chain = new X509Certificate[] { KeystoreTestUtils.generateExpiredSelfSignedCertificate() }; + final X509Certificate[] chain = new X509Certificate[] { KeystoreTestUtils.generateExpiredSelfSignedCertificate().getCertificate() }; final X509CertSelector selector = new X509CertSelector(); selector.setSubject( chain[ 0 ].getSubjectX500Principal() ); diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/keystore/KeystoreTestUtils.java b/xmppserver/src/test/java/org/jivesoftware/openfire/keystore/KeystoreTestUtils.java index 39d64a997c..9f7f7b6da7 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/keystore/KeystoreTestUtils.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/keystore/KeystoreTestUtils.java @@ -1,21 +1,32 @@ package org.jivesoftware.openfire.keystore; import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x500.X500NameBuilder; +import org.bouncycastle.asn1.x500.style.BCStyle; import org.bouncycastle.asn1.x509.BasicConstraints; import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.cert.X509v3CertificateBuilder; import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.ContentVerifierProvider; import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder; import org.jivesoftware.util.Base64; +import javax.annotation.Nullable; import java.math.BigInteger; import java.security.*; -import java.security.cert.X509Certificate; -import java.util.Date; +import java.security.cert.*; +import java.sql.Date; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.zip.Adler32; /** * Utility functions that are intended to be used by unit tests. @@ -25,6 +36,13 @@ public class KeystoreTestUtils { private static final Provider PROVIDER = new BouncyCastleProvider(); + private static final Object BEGIN_CERT = "-----BEGIN CERTIFICATE-----"; + private static final Object END_CERT = "-----END CERTIFICATE-----"; + + public static final int CHAIN_LENGTH = 4; + public static final int KEY_SIZE = 2048; + public static final String KEY_ALGORITHM = "RSA"; + public static final String SIGNATURE_ALGORITHM = "SHA1withRSA"; static { @@ -32,6 +50,18 @@ public class KeystoreTestUtils Security.addProvider( PROVIDER ); } + /** + * Returns the Privacy Enhanced Mail (PEM) format of a X509 certificate. + * + * @param certificate An X509 certificate (cannot be null). + * @return a PEM representation of the certificate (never null, never an empty string). + */ + public static String toPemFormat( X509Certificate certificate ) throws Exception { + return String.valueOf(BEGIN_CERT) + '\n' + + Base64.encodeBytes(certificate.getEncoded()) + '\n' + + END_CERT + '\n'; + } + /** * Generates a chain of certificates, where the first certificate represents the end-entity certificate and the last * certificate represents the trust anchor (the 'root certificate'). @@ -49,27 +79,55 @@ public class KeystoreTestUtils * * @return an array of certificates. Never null, never an empty array. */ - public static X509Certificate[] generateValidCertificateChain() throws Exception + public static ResultHolder generateValidCertificateChain() throws Exception { - int length = 4; - final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance( "RSA" ); - keyPairGenerator.initialize( 512 ); - - // Root certificate (representing the CA) is self-signed. - KeyPair subjectKeyPair = keyPairGenerator.generateKeyPair(); - KeyPair issuerKeyPair = subjectKeyPair; + return generateValidCertificateChain(null); + } - final X509Certificate[] result = new X509Certificate[ length ]; - for ( int i = length - 1 ; i >= 0; i-- ) - { - result[ i ] = generateTestCertificate( true, issuerKeyPair, subjectKeyPair, i ); + /** + * Generates a chain of certificates, where the first certificate represents the end-entity certificate and the last + * certificate represents the trust anchor (the 'root certificate'). + * + * Exactly four certificates are returned: + *
    + *
  1. The end-entity certificate
  2. + *
  3. an intermediate CA certificate
  4. + *
  5. a different intermediate CA certificate
  6. + *
  7. a root CA certificate
  8. + *
+ * + * Each certificate is issued by the certificate that's in the next position of the chain. The last certificate is + * self-signed. + * + * @param subjectCommonName The CN value to use in the end-entity certificate. + * @return an array of certificates. Never null, never an empty array. + */ + public static ResultHolder generateValidCertificateChain(@Nullable final String subjectCommonName) throws Exception + { + return generateCertificateChainWithExpiredCertOnPosition(subjectCommonName, -1); + } - // Further away from the root CA, each certificate is issued by the previous subject. - issuerKeyPair = subjectKeyPair; - subjectKeyPair = keyPairGenerator.generateKeyPair(); - } + /** + * Generates a chain of certificates, identical to {@link #generateValidCertificateChain()}, with one exception: + * the first certificate (the end-entity) is expired. + * + * @return an array of certificates. Never null, never an empty array. + */ + public static ResultHolder generateCertificateChainWithExpiredEndEntityCert() throws Exception + { + return generateCertificateChainWithExpiredEndEntityCert(null); + } - return result; + /** + * Generates a chain of certificates, identical to {@link #generateValidCertificateChain()}, with one exception: + * the first certificate (the end-entity) is expired. + * + * @param subjectCommonName The CN value to use in the end-entity certificate. + * @return an array of certificates. Never null, never an empty array. + */ + public static ResultHolder generateCertificateChainWithExpiredEndEntityCert(@Nullable final String subjectCommonName) throws Exception + { + return generateCertificateChainWithExpiredCertOnPosition(subjectCommonName, 0); } /** @@ -78,79 +136,102 @@ public static X509Certificate[] generateValidCertificateChain() throws Exception * * @return an array of certificates. Never null, never an empty array. */ - public static X509Certificate[] generateCertificateChainWithExpiredIntermediateCert() throws Exception + public static ResultHolder generateCertificateChainWithExpiredIntermediateCert() throws Exception { - int length = 4; - final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance( "RSA" ); - keyPairGenerator.initialize( 512 ); - - // Root certificate (representing the CA) is self-signed. - KeyPair subjectKeyPair = keyPairGenerator.generateKeyPair(); - KeyPair issuerKeyPair = subjectKeyPair; - - final X509Certificate[] result = new X509Certificate[ length ]; - for ( int i = length - 1 ; i >= 0; i-- ) - { - boolean isValid = ( i != 1 ); // second certificate needs to be expired! - result[ i ] = generateTestCertificate( isValid, issuerKeyPair, subjectKeyPair, i ); + return generateCertificateChainWithExpiredIntermediateCert(null); + } - // Further away from the root CA, each certificate is issued by the previous subject. - issuerKeyPair = subjectKeyPair; - subjectKeyPair = keyPairGenerator.generateKeyPair(); - } + /** + * Generates a chain of certificates, identical to {@link #generateValidCertificateChain()}, with one exception: + * the second certificate (the first intermediate) is expired. + * + * @param subjectCommonName The CN value to use in the end-entity certificate. + * @return an array of certificates. Never null, never an empty array. + */ + public static ResultHolder generateCertificateChainWithExpiredIntermediateCert(@Nullable final String subjectCommonName) throws Exception + { + return generateCertificateChainWithExpiredCertOnPosition(subjectCommonName, 1); + } - return result; + /** + * Generates a chain of certificates, identical to {@link #generateValidCertificateChain()}, with one exception: + * the last certificate (the root CA) is expired. + * + * @return an array of certificates. Never null, never an empty array. + */ + public static ResultHolder generateCertificateChainWithExpiredRootCert() throws Exception { + return generateCertificateChainWithExpiredRootCert(null); } /** * Generates a chain of certificates, identical to {@link #generateValidCertificateChain()}, with one exception: * the last certificate (the root CA) is expired. * + * @param subjectCommonName The CN value to use in the end-entity certificate. * @return an array of certificates. Never null, never an empty array. */ - public static X509Certificate[] generateCertificateChainWithExpiredRootCert() throws Exception + public static ResultHolder generateCertificateChainWithExpiredRootCert(@Nullable final String subjectCommonName) throws Exception { - int length = 4; - final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance( "RSA" ); - keyPairGenerator.initialize( 512 ); + return generateCertificateChainWithExpiredCertOnPosition(subjectCommonName, CHAIN_LENGTH - 1); + } + + /** + * Generates a chain of certificates, identical to {@link #generateValidCertificateChain()}, with one exception: + * one certificate in the chain (identified by its number in the chain, provided by the second argument) is expired. + * + * @param subjectCommonName The CN value to use in the end-entity certificate. + * @param certificateInChainThatIsExpired certificate position in chain that is expired (zero-based). + * @return an array of certificates. Never null, never an empty array. + */ + private static ResultHolder generateCertificateChainWithExpiredCertOnPosition(@Nullable final String subjectCommonName, final int certificateInChainThatIsExpired) throws Exception + { + final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance( KEY_ALGORITHM ); + keyPairGenerator.initialize( KEY_SIZE ); // Root certificate (representing the CA) is self-signed. KeyPair subjectKeyPair = keyPairGenerator.generateKeyPair(); KeyPair issuerKeyPair = subjectKeyPair; - final X509Certificate[] result = new X509Certificate[ length ]; - for ( int i = length - 1 ; i >= 0; i-- ) + final X509Certificate[] result = new X509Certificate[ CHAIN_LENGTH ]; + for ( int i = CHAIN_LENGTH - 1 ; i >= 0; i-- ) { - boolean isValid = ( i != length - 1 ); // root certificate needs to be expired! - result[ i ] = generateTestCertificate( isValid, issuerKeyPair, subjectKeyPair, i ); + boolean isValid = ( i != certificateInChainThatIsExpired ); // one certificate needs to be expired! + result[ i ] = generateTestCertificate( i == 0 ? subjectCommonName : null, isValid, issuerKeyPair, subjectKeyPair, i ); // Further away from the root CA, each certificate is issued by the previous subject. issuerKeyPair = subjectKeyPair; subjectKeyPair = keyPairGenerator.generateKeyPair(); } - return result; + // Note that the issuerKeyPair now holds the subjectKeyPair that was used last! SubjectKeyPair now holds an unused value. + return new ResultHolder(issuerKeyPair, result); } - private static X509Certificate generateTestCertificate( final boolean isValid, final KeyPair issuerKeyPair, final KeyPair subjectKeyPair, int indexAwayFromEndEntity) throws Exception + private static X509Certificate generateTestCertificate(@Nullable final String subjectCommonName, final boolean isValid, final KeyPair issuerKeyPair, final KeyPair subjectKeyPair, int indexAwayFromEndEntity) throws Exception { // Issuer and Subject. - final X500Name subject = new X500Name( "CN=" + Base64.encodeBytes( subjectKeyPair.getPublic().getEncoded(), Base64.URL_SAFE ) ); - final X500Name issuer = new X500Name( "CN=" + Base64.encodeBytes( issuerKeyPair.getPublic().getEncoded(), Base64.URL_SAFE ) ); + final String subjectName = (subjectCommonName != null ? subjectCommonName : asSemiUniqueName(subjectKeyPair.getPublic())); + final String issuerName = issuerKeyPair == subjectKeyPair ? subjectName : asSemiUniqueName(issuerKeyPair.getPublic()); + final X500NameBuilder subjectBuilder = new X500NameBuilder(); + subjectBuilder.addRDN(BCStyle.CN, subjectName); + final X500Name subject = subjectBuilder.build(); + final X500NameBuilder issuerBuilder = new X500NameBuilder(); + issuerBuilder.addRDN(BCStyle.CN, issuerName); + final X500Name issuer = issuerBuilder.build(); // Validity - final Date notBefore; - final Date notAfter; + final Instant notBefore; + final Instant notAfter; if ( isValid ) { - notBefore = new Date( System.currentTimeMillis() - ( 1000L * 60 * 60 * 24 * 30 ) ); // 30 days ago - notAfter = new Date( System.currentTimeMillis() + ( 1000L * 60 * 60 * 24 * 99 ) ); // 99 days from now. + notBefore = Instant.now().minus(30, ChronoUnit.DAYS); // 30 days ago + notAfter = Instant.now().plus(99, ChronoUnit.DAYS); // 99 days from now. } else { // Generate a certificate for which the validate period has expired. - notBefore = new Date( System.currentTimeMillis() - ( 1000L * 60 * 60 * 24 * 40 ) ); // 40 days ago - notAfter = new Date( System.currentTimeMillis() - ( 1000L * 60 * 60 * 24 * 10 ) ); // 10 days ago + notBefore = Instant.now().minus(40, ChronoUnit.DAYS); // 40 days ago + notAfter = Instant.now().minus(10, ChronoUnit.DAYS); // 10 days ago } // The new certificate should get a unique serial number. @@ -159,8 +240,8 @@ private static X509Certificate generateTestCertificate( final boolean isValid, f final X509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( issuer, serial, - notBefore, - notAfter, + Date.from(notBefore), + Date.from(notAfter), subject, subjectKeyPair.getPublic() ); @@ -171,9 +252,24 @@ private static X509Certificate generateTestCertificate( final boolean isValid, f builder.addExtension( Extension.basicConstraints, true, new BasicConstraints( indexAwayFromEndEntity - 1 ) ); } - final ContentSigner contentSigner = new JcaContentSignerBuilder( "SHA1withRSA" ).build( issuerKeyPair.getPrivate() ); + // add subjectAlternativeName extension that includes all relevant names. + builder.addExtension(Extension.subjectAlternativeName, false, + new GeneralNames(new GeneralName(GeneralName.dNSName, subjectName))); + + // Add keyIdentifiers extensions + final JcaX509ExtensionUtils utils = new JcaX509ExtensionUtils(); + builder.addExtension(Extension.subjectKeyIdentifier, false, utils.createSubjectKeyIdentifier(subjectKeyPair.getPublic())); + builder.addExtension(Extension.authorityKeyIdentifier, false, utils.createAuthorityKeyIdentifier(issuerKeyPair.getPublic())); + + // Build the certificate. + final ContentSigner contentSigner = new JcaContentSignerBuilder(SIGNATURE_ALGORITHM).build( issuerKeyPair.getPrivate() ); final X509CertificateHolder certificateHolder = builder.build( contentSigner ); + // Verify the signature. + final ContentVerifierProvider verifierProvider = new JcaContentVerifierProviderBuilder().build(issuerKeyPair.getPublic()); + if (!certificateHolder.isSignatureValid(verifierProvider)) { + throw new GeneralSecurityException("Certificate signature not valid"); + } return new JcaX509CertificateConverter().setProvider( "BC" ).getCertificate( certificateHolder ); } @@ -184,9 +280,21 @@ private static X509Certificate generateTestCertificate( final boolean isValid, f * * @return A certificate that is invalid (never null). */ - public static X509Certificate generateExpiredCertificate() throws Exception + public static ResultHolder generateExpiredCertificate() throws Exception + { + return generateTestCertificate( null, false, false, 0 ); + } + + /** + * Instantiates a new certificate of which the notAfter value is a point in time that is in the past (as compared + * to the point in time of the invocation of this method). + * + * @param subjectCommonName The CN value to use in the certificate. + * @return A certificate that is invalid (never null). + */ + public static ResultHolder generateExpiredCertificate(@Nullable final String subjectCommonName) throws Exception { - return generateTestCertificate( false, false, 0 ); + return generateTestCertificate( subjectCommonName, false, false, 0 ); } /** @@ -200,9 +308,26 @@ public static X509Certificate generateExpiredCertificate() throws Exception * * @return A certificate that is valid (never null). */ - public static X509Certificate generateValidCertificate() throws Exception + public static ResultHolder generateValidCertificate() throws Exception { - return generateTestCertificate( true, false, 0 ); + return generateTestCertificate( null, true, false, 0 ); + } + + /** + * Instantiates a new certificate of which the notBefore value is a point in the past, and the notAfter value is a + * point in the future (as compared to the point in time of the invocation of this method). + * + * The notAfter value can be expected to be a value that is far enough in the future for unit testing purposes, but + * should not be assumed to be a value that is in the distant future. It is safe to assume that the generated + * certificate will remain to be valid for the duration of a generic unit test (which is measured in seconds or + * fractions thereof). + * + * @param subjectCommonName The CN value to use in the certificate. + * @return A certificate that is valid (never null). + */ + public static ResultHolder generateValidCertificate(@Nullable final String subjectCommonName ) throws Exception + { + return generateTestCertificate( subjectCommonName, true, false, 0 ); } /** @@ -213,9 +338,23 @@ public static X509Certificate generateValidCertificate() throws Exception * @return A certificate that is self-signed (never null). * @see #generateValidCertificate() */ - public static X509Certificate generateSelfSignedCertificate() throws Exception + public static ResultHolder generateSelfSignedCertificate() throws Exception { - return generateTestCertificate( true, true, 0 ); + return generateTestCertificate( null, true, true, 0 ); + } + + /** + * Instantiates a new certificate that is self-signed, meaning that the issuer and subject values are identical. The + * returned certificate is valid in the same manner as described in the documentation of + * {@link #generateValidCertificate()}. + * + * @param subjectCommonName The CN value to use in the certificate. + * @return A certificate that is self-signed (never null). + * @see #generateValidCertificate() + */ + public static ResultHolder generateSelfSignedCertificate(@Nullable final String subjectCommonName ) throws Exception + { + return generateTestCertificate( subjectCommonName, true, true, 0 ); } /** @@ -226,15 +365,29 @@ public static X509Certificate generateSelfSignedCertificate() throws Exception * @see #generateSelfSignedCertificate() * @see #generateExpiredCertificate() */ - public static X509Certificate generateExpiredSelfSignedCertificate() throws Exception + public static ResultHolder generateExpiredSelfSignedCertificate() throws Exception { - return generateTestCertificate( false, true, 0 ); + return generateTestCertificate( null, false, true, 0 ); } - private static X509Certificate generateTestCertificate( final boolean isValid, final boolean isSelfSigned, int indexAwayFromEndEntity ) throws Exception + /** + * Instantiates a new certificate that is self-signed, of which the notAfter value is a point in time that is in the + * past (as compared to the point in time of the invocation of this method). + * + * @param subjectCommonName The CN value to use in the certificate. + * @return A certificate that is self-signed and expired (never null). + * @see #generateSelfSignedCertificate() + * @see #generateExpiredCertificate() + */ + public static ResultHolder generateExpiredSelfSignedCertificate(@Nullable final String subjectCommonName ) throws Exception { - final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance( "RSA" ); - keyPairGenerator.initialize( 512 ); + return generateTestCertificate( subjectCommonName, false, true, 0 ); + } + + private static ResultHolder generateTestCertificate(@Nullable final String subjectCommonName, final boolean isValid, final boolean isSelfSigned, int indexAwayFromEndEntity ) throws Exception + { + final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance( KEY_ALGORITHM ); + keyPairGenerator.initialize( KEY_SIZE ); final KeyPair subjectKeyPair; final KeyPair issuerKeyPair; @@ -251,6 +404,60 @@ private static X509Certificate generateTestCertificate( final boolean isValid, f issuerKeyPair = keyPairGenerator.generateKeyPair(); } - return generateTestCertificate( isValid, issuerKeyPair, subjectKeyPair, indexAwayFromEndEntity ); + final X509Certificate subjectCertificate = generateTestCertificate(subjectCommonName, isValid, issuerKeyPair, subjectKeyPair, indexAwayFromEndEntity); + return new ResultHolder(subjectKeyPair, subjectCertificate); + } + + /** + * Generate a short semi-unique string to identify a public key. + * + * This method uses a very short checksum algorithm. The benefit of this is that the generated values are + * human-readable. The downside is that the generated values are not truly unique to the public key. As this method + * is part of unit testing, that should not be to much of a problem. + * + * @param publicKey For which to generate an identifier + * @return an identifier + */ + public static String asSemiUniqueName(final PublicKey publicKey) { + final byte[] bytes = publicKey.getEncoded(); + final Adler32 checksum = new Adler32(); + checksum.update(bytes,0,bytes.length); + return Long.toHexString(checksum.getValue()); + } + + /** + * A data structure that holds a generated key pair and the associated certificate chain. + */ + public static class ResultHolder + { + private final KeyPair keyPair; + private final X509Certificate[] certificateChain; + + public ResultHolder(final KeyPair keyPair, final X509Certificate certificate) + { + this.keyPair = keyPair; + this.certificateChain = new X509Certificate[] { certificate }; + } + + public ResultHolder(final KeyPair keyPair, final X509Certificate[] certificateChain) + { + this.keyPair = keyPair; + this.certificateChain = certificateChain; + } + + public KeyPair getKeyPair() + { + return keyPair; + } + + public X509Certificate getCertificate() + { + return certificateChain[0]; + } + + public X509Certificate[] getCertificateChain() + { + return certificateChain; + } } } diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/keystore/OpenfireX509TrustManagerTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/keystore/OpenfireX509TrustManagerTest.java index 023f11dddd..b3c452c3b7 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/keystore/OpenfireX509TrustManagerTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/keystore/OpenfireX509TrustManagerTest.java @@ -67,10 +67,10 @@ public void createFixture() throws Exception trustStore.load( null, password.toCharArray() ); // Populate the store with a valid CA certificate. - validChain = KeystoreTestUtils.generateValidCertificateChain(); - expiredIntChain = KeystoreTestUtils.generateCertificateChainWithExpiredIntermediateCert(); - expiredRootChain = KeystoreTestUtils.generateCertificateChainWithExpiredRootCert(); - untrustedCAChain = KeystoreTestUtils.generateValidCertificateChain(); + validChain = KeystoreTestUtils.generateValidCertificateChain().getCertificateChain(); + expiredIntChain = KeystoreTestUtils.generateCertificateChainWithExpiredIntermediateCert().getCertificateChain(); + expiredRootChain = KeystoreTestUtils.generateCertificateChainWithExpiredRootCert().getCertificateChain(); + untrustedCAChain = KeystoreTestUtils.generateValidCertificateChain().getCertificateChain(); trustStore.setCertificateEntry( getLast( validChain ).getSubjectDN().getName(), getLast( validChain ) ); trustStore.setCertificateEntry( getLast( expiredIntChain ).getSubjectDN().getName(), getLast( expiredIntChain ) ); diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/session/AbstractRemoteServerDummy.java b/xmppserver/src/test/java/org/jivesoftware/openfire/session/AbstractRemoteServerDummy.java new file mode 100644 index 0000000000..189ac5f680 --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/session/AbstractRemoteServerDummy.java @@ -0,0 +1,237 @@ +/* + * Copyright (C) 2023 Ignite Realtime Foundation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.openfire.session; + +import org.dom4j.DocumentException; +import org.dom4j.DocumentHelper; +import org.dom4j.Element; +import org.jivesoftware.openfire.Connection; +import org.jivesoftware.openfire.keystore.KeystoreTestUtils; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509KeyManager; +import javax.net.ssl.X509TrustManager; +import java.net.Socket; +import java.security.KeyPair; +import java.security.Principal; +import java.security.PrivateKey; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.time.Instant; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class AbstractRemoteServerDummy +{ + public static final String XMPP_DOMAIN = "remote-dummy.example.org"; + + private final static KeystoreTestUtils.ResultHolder SELF_SIGNED_CERTIFICATE; + private final static KeystoreTestUtils.ResultHolder EXPIRED_SELF_SIGNED_CERTIFICATE; + private final static KeystoreTestUtils.ResultHolder VALID_CERTIFICATE_CHAIN; + private final static KeystoreTestUtils.ResultHolder EXPIRED_CERTIFICATE_CHAIN; + + public static final Duration SO_TIMEOUT = Duration.ofMillis(50); + protected boolean useExpiredEndEntityCertificate; + protected boolean useSelfSignedCertificate; + protected boolean disableDialback; + protected Connection.TLSPolicy encryptionPolicy = Connection.TLSPolicy.optional; + protected KeystoreTestUtils.ResultHolder generatedPKIX; + + static { + // Generating certificates is expensive. For performance, it's best to generate each set once, and then reuse those during the execution of the tests. + try { + SELF_SIGNED_CERTIFICATE = KeystoreTestUtils.generateSelfSignedCertificate(XMPP_DOMAIN); + EXPIRED_SELF_SIGNED_CERTIFICATE = KeystoreTestUtils.generateExpiredSelfSignedCertificate(XMPP_DOMAIN); + VALID_CERTIFICATE_CHAIN = KeystoreTestUtils.generateValidCertificateChain(XMPP_DOMAIN); + EXPIRED_CERTIFICATE_CHAIN = KeystoreTestUtils.generateCertificateChainWithExpiredEndEntityCert(XMPP_DOMAIN); + } catch (Throwable t) { + throw new IllegalStateException("Unable to setup certificates used by the test implementation.", t); + } + } + + /** + * Updates the TLS encryption policy that's observed by this server. + */ + public void setEncryptionPolicy(Connection.TLSPolicy encryptionPolicy) + { + this.encryptionPolicy = encryptionPolicy; + } + + /** + * When set to 'true', this instance will identify itself with a TLS certificate that is self-signed. + * + * Must be invoked before {@link #preparePKIX()} is invoked. + * + * @param useSelfSignedCertificate 'true' to use a self-signed certificate + */ + public void setUseSelfSignedCertificate(boolean useSelfSignedCertificate) + { + if (generatedPKIX != null) { + throw new IllegalStateException("Cannot change PKIX settings after PKIX has been prepared."); + } + this.useSelfSignedCertificate = useSelfSignedCertificate; + } + + /** + * When set to 'true', this instance will identify itself with a TLS certificate that is expired (its 'notBefore' + * and 'notAfter' values define a period of validity that does not include the current date and time). + * + * Must be invoked before {@link #preparePKIX()} is invoked. + * + * @param useExpiredEndEntityCertificate 'true' to use an expired certificate + */ + public void setUseExpiredEndEntityCertificate(boolean useExpiredEndEntityCertificate) + { + if (generatedPKIX != null) { + throw new IllegalStateException("Cannot change PKIX settings after PKIX has been prepared."); + } + this.useExpiredEndEntityCertificate = useExpiredEndEntityCertificate; + } + + /** + * When set to 'true', this instance will NOT advertise support for the Dialback authentication mechanism, and will + * reject Dialback authentication attempts. + */ + public void setDisableDialback(boolean disableDialback) { + this.disableDialback = disableDialback; + } + + /** + * Generates KeyPairs and certificates that are used to identify this server using TLS. + * + * The data that is generated by this method can be configured by invoking methods such as + * {@link #setUseSelfSignedCertificate(boolean)} and + * {@link #setUseExpiredEndEntityCertificate(boolean)}. These must be invoked before invoking #preparePKIX + */ + public void preparePKIX() throws Exception + { + if (generatedPKIX != null) { + throw new IllegalStateException("PKIX already prepared."); + } + + if (useSelfSignedCertificate) { + generatedPKIX = useExpiredEndEntityCertificate ? EXPIRED_SELF_SIGNED_CERTIFICATE : SELF_SIGNED_CERTIFICATE; + } else { + generatedPKIX = useExpiredEndEntityCertificate ? EXPIRED_CERTIFICATE_CHAIN : VALID_CERTIFICATE_CHAIN; + } + } + + /** + * Returns the KeyPairs and certificates that are used to identify this server using TLS. + * + * @return TLS identification material for this server. + */ + public KeystoreTestUtils.ResultHolder getGeneratedPKIX() { + return generatedPKIX; + } + + /** + * Parses text as an XML element. + * + * When the provided input is an element that is not closed, then a closing element is automatically generated. This + * helps to parse `stream` elements, that are closed only when the XMPP session ends. + * + * @param xml The data to parse + * @return an XML element + */ + public static Element parse(final String xml) throws DocumentException + { + String toParse = xml; + + // Verify if xml ends with close tag that matches the first tag. + final Matcher matcher = Pattern.compile("[A-Za-z:]+").matcher(xml); + if (matcher.find()) { + final String fakeEndTag = ""; + final String emptyElementTagPattern = "<" + matcher.group() + "[^/>]*/>"; + if (!xml.trim().endsWith(fakeEndTag) && !Pattern.compile(emptyElementTagPattern).matcher(xml).find()) { + toParse += fakeEndTag; + } + } + return DocumentHelper.parseText(toParse).getRootElement(); + } + + /** + * Creates a TrustManager that will blindly accept all certificates. + */ + public static TrustManager[] createTrustManagerThatTrustsAll() + { + // Create a trust manager that does not validate certificate chains + return new TrustManager[]{ + new X509TrustManager() + { + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + + public void checkClientTrusted(X509Certificate[] certs, String authType) throws CertificateException + { + if (Instant.now().isAfter(certs[0].getNotAfter().toInstant()) || Instant.now().isBefore(certs[0].getNotBefore().toInstant())) { + throw new CertificateException("Peer certificate is expired."); + } + } + + public void checkServerTrusted(X509Certificate[] certs, String authType) throws CertificateException { + if (Instant.now().isAfter(certs[0].getNotAfter().toInstant()) || Instant.now().isBefore(certs[0].getNotBefore().toInstant())) { + throw new CertificateException("Peer certificate is expired."); + } + } + } + }; + } + + /** + * Creates a KeyManager that identifies with the provided keyPair and certificate chain. + */ + public static KeyManager[] createKeyManager(final KeyPair keyPair, final X509Certificate... chain) + { + return new KeyManager[]{ + new X509KeyManager() + { + @Override + public String[] getClientAliases(String keyType, Principal[] issuers) { + return new String[] { XMPP_DOMAIN }; + } + + @Override + public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { + return XMPP_DOMAIN; + } + + @Override + public String[] getServerAliases(String keyType, Principal[] issuers) { + return new String[] { XMPP_DOMAIN }; + } + + @Override + public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) { + return XMPP_DOMAIN; + } + + @Override + public X509Certificate[] getCertificateChain(String alias) { + return chain; + } + + @Override + public PrivateKey getPrivateKey(String alias) { + return keyPair == null ? null : keyPair.getPrivate(); + } + } + }; + } +} diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/session/ExpectedOutcome.java b/xmppserver/src/test/java/org/jivesoftware/openfire/session/ExpectedOutcome.java new file mode 100644 index 0000000000..986254be95 --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/session/ExpectedOutcome.java @@ -0,0 +1,319 @@ +/* + * Copyright (C) 2023 Ignite Realtime Foundation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.openfire.session; + +import java.util.HashSet; +import java.util.Set; + +import static org.jivesoftware.openfire.session.ExpectedOutcome.ConnectionState.*; + +/** + * Given XMPP federation, this class defines the expected result of one domain trying to establish an authenticated + * connection to another domain. + * + * @see XEP-0220: Server Dialback + * @see XEP-0178: Best Practices for Use of SASL EXTERNAL with Certificates + * @see Use of Transport Layer Security (TLS) in the Extensible Messaging and Presence Protocol (XMPP) + * @see OF-2611: Improve automated tests for S2S functionality + * @see OF-2591: S2S Outbound should allow encryption if Dialback used for authentication + */ +public class ExpectedOutcome +{ + /** + * Enumeration of possible connection states. + */ + public enum ConnectionState + { + /** + * Connection cannot be established. In the current implementation, this includes scenarios in which connections could not be authenticated. + */ + NO_CONNECTION("(no conn)"), + + /** + * Connection without encryption, Initiating Entity is authenticated by the Receiving Entity using the Dialback protocol. + */ + NON_ENCRYPTED_WITH_DIALBACK_AUTH("PLAIN DIALB"), + + /** + * Connection that is encrypted, Initiating Entity is authenticated by the Receiving Entity using the Dialback protocol. + */ + ENCRYPTED_WITH_DIALBACK_AUTH("TLS DIALB"), + + /** + * Connection that is encrypted, Initiating Entity is authenticated by the Receiving Entity using the SASL EXTERNAL mechanism. + */ + ENCRYPTED_WITH_SASLEXTERNAL_AUTH("SASL_EXT"); + + final String shortCode; + ConnectionState(String shortCode) { + this.shortCode = shortCode; + } + + public String getShortCode() { + return shortCode; + } + } + + private final Set rationales = new HashSet<>(); + private ConnectionState connectionState; + + public void set(final ConnectionState state, final String rationale) + { + if (connectionState != null && connectionState != state) { + throw new IllegalStateException("Cannot set state " + state + ". State already is " + connectionState); + } + connectionState = state; + rationales.add(rationale); + } + + public ConnectionState getConnectionState() + { + return connectionState; + } + + public Set getRationales() + { + return rationales; + } + + public boolean isInconclusive() + { + return connectionState == null; + } + + /** + * Given the configuration of the initiating and receiving server, returns the expected outcome of an outbound + * server-to-server connection attempt. + * + * @param initiatingServer Configuration of the local server + * @param receivingServer Configuration of the remote server + * @return the expected outcome + */ + public static ExpectedOutcome generateExpectedOutcome(final ServerSettings initiatingServer, final ServerSettings receivingServer) { + final ExpectedOutcome expectedOutcome = new ExpectedOutcome(); + + switch (initiatingServer.encryptionPolicy) { + case disabled: // <-- Initiating server's encryption policy. + switch (receivingServer.encryptionPolicy) { + case disabled: // Intended fall-through: if one peer disables TLS, it won't be used in any circumstances. + case optional: + // The certificate status of both peers is irrelevant, as TLS won't happen. + if (initiatingServer.dialbackSupported && receivingServer.dialbackSupported) { + expectedOutcome.set(NON_ENCRYPTED_WITH_DIALBACK_AUTH, "although TLS is not available (so it cannot be used for encryption or authentication), Dialback is available, which allows the Initiating Entity to be authenticated by the Receiving Entity."); + } else { + expectedOutcome.set(NO_CONNECTION, "TLS and Dialback aren't available, making it impossible for the Initiating Entity to be authenticated by the Receiving Entity."); + } + break; + case required: + expectedOutcome.set(NO_CONNECTION, "one peer requires encryption while the other disables encryption. This cannot work."); + break; + } + break; + + case optional: // <-- Initiating server's encryption policy. + switch (receivingServer.encryptionPolicy) { + case disabled: + // The certificate status of both peers is irrelevant, as TLS won't happen. + if (initiatingServer.dialbackSupported && receivingServer.dialbackSupported) { + expectedOutcome.set(NON_ENCRYPTED_WITH_DIALBACK_AUTH, "TLS is not available, so it cannot be used for encryption or authentication. Dialback is available, which allows the Initiating Entity to be authenticated by the Receiving Entity."); + } else { + expectedOutcome.set(NO_CONNECTION, "TLS and Dialback aren't available, making it impossible for the Initiating Entity to be authenticated by the Receiving Entity."); + } + break; + case optional: + switch (receivingServer.certificateState) { + case MISSING: + if (initiatingServer.dialbackSupported && receivingServer.dialbackSupported) { + // TODO: should we take into account a manual configuration of an ANON cypher suite, so that encryption-without-authentication can occur via TLS, followed by a Dialback-based authentication? + expectedOutcome.set(NON_ENCRYPTED_WITH_DIALBACK_AUTH, "Receiving Entity does not provides a TLS certificate. As ANON cypher suites are expected to be unavailable, Initiating Entity cannot negotiate TLS, so that cannot be used for encryption or authentication. Dialback is available, so authentication can occur."); + } else { + expectedOutcome.set(NO_CONNECTION, "Receiving Entity does not provides a TLS certificate, Initiating Entity cannot negotiate TLS. With TLS and Dialback unavailable, authentication cannot occur (even if usage of an ANON cypher suite would make TLS-for-encryption possible)"); + } + break; + case INVALID: + if (initiatingServer.strictCertificateValidation) { + expectedOutcome.set(NO_CONNECTION, "Receiving Entity provides an invalid TLS certificate, which should cause Initiating Entity to abort TLS (as per RFC 6120 section 13.7.2)."); + } else if (initiatingServer.dialbackSupported && receivingServer.dialbackSupported) { + expectedOutcome.set(ENCRYPTED_WITH_DIALBACK_AUTH, "Initiating Entity may choose to ignore Receiving Entities invalid certificate (for encryption purposes only) and choose to authenticate with Server Dialback (per RFC 7590 Section 3.4)"); + } else { + expectedOutcome.set(NO_CONNECTION, "Receiving Entity provides an invalid TLS certificate. As Server Dialback is not available, authentication cannot occur."); + } + break; + case VALID: + switch (initiatingServer.certificateState) { + case MISSING: + if (initiatingServer.dialbackSupported && receivingServer.dialbackSupported) { + expectedOutcome.set(ENCRYPTED_WITH_DIALBACK_AUTH, "Receiving Entity provides a valid TLS certificate allowing Initiating Entity to be able to establish TLS for encryption. Initiating Entity but does not provide a certificate itself, so SASL EXTERNAL is not available for Receiving Entity to authenticate Initiating Entity, but Dialback is available for that purpose."); + } else { + expectedOutcome.set(NO_CONNECTION, "Receiving Entity provides a valid TLS certificate allowing Initiating Entity to be able to establish TLS for encryption. Initiating Entity but does not provide a certificate itself, so SASL EXTERNAL is not available for Receiving Entity to authenticate Initiating Entity. As Dialback is also not available, authentication cannot occur."); + } + break; + case INVALID: + if (receivingServer.strictCertificateValidation) { + expectedOutcome.set(NO_CONNECTION, "Initiating Entity provides an invalid TLS certificate, which should cause Receiving Entity to abort TLS (as per RFC 6120 section 13.7.2)."); + } else if (initiatingServer.dialbackSupported && receivingServer.dialbackSupported) { + expectedOutcome.set(ENCRYPTED_WITH_DIALBACK_AUTH, "Receiving Entity may choose to ignore Initiating Entities invalid certificate (for encryption purposes only) and choose to authenticate with Server Dialback (per RFC 7590 Section 3.4)"); + } else { + expectedOutcome.set(NO_CONNECTION, "Initiating Entity provides an invalid TLS certificate. As Server Dialback is not available, authentication cannot occur."); + } + break; + case VALID: + // valid certs all around. Dialback is not needed. + expectedOutcome.set(ENCRYPTED_WITH_SASLEXTERNAL_AUTH, "TLS is usable for both encryption and authentication."); + break; + } + break; + } + break; + case required: + switch (receivingServer.certificateState) { + case MISSING: + expectedOutcome.set(NO_CONNECTION, "Receiving Entity requires encryption, but it does not provide a TLS certificate. As ANON cypher suites are expected to be unavailable, the Initiating Entity cannot negotiate TLS and therefor the required encrypted connection cannot be established."); + // TODO: should we take into account a manual configuration of an ANON cypher suite, so that encryption-without-authentication can occur via TLS, followed by a Dialback-based authentication? + break; + case INVALID: + if (initiatingServer.strictCertificateValidation) { + expectedOutcome.set(NO_CONNECTION, "Receiving Entity provides an invalid TLS certificate, which should cause Initiating Entity to abort TLS (as per RFC 6120 section 13.7.2)."); + } else if (initiatingServer.dialbackSupported && receivingServer.dialbackSupported) { + expectedOutcome.set(ENCRYPTED_WITH_DIALBACK_AUTH, "Initiating Entity may choose to ignore Receiving Entities invalid certificate (for encryption purposes only) and choose to authenticate with Server Dialback (per RFC 7590 Section 3.4)"); + } else { + expectedOutcome.set(NO_CONNECTION, "Receiving Entity provides an invalid TLS certificate. As Server Dialback is not available, authentication cannot occur."); + } + break; + case VALID: + switch (initiatingServer.certificateState) { + case MISSING: + if (initiatingServer.dialbackSupported && receivingServer.dialbackSupported) { + expectedOutcome.set(ENCRYPTED_WITH_DIALBACK_AUTH, "Receiving Entity provides a valid TLS certificate allowing Initiating Entity to be able to establish TLS for encryption. Initiating Entity but does not provide a certificate itself, so SASL EXTERNAL is not available for Receiving Entity to authenticate Initiating Entity, but Dialback is available for that purpose."); + } else { + expectedOutcome.set(NO_CONNECTION, "Receiving Entity provides a valid TLS certificate allowing Initiating Entity to be able to establish TLS for encryption. Initiating Entity but does not provide a certificate itself, so SASL EXTERNAL is not available for Receiving Entity to authenticate Initiating Entity. As Dialback is also not available, authentication cannot occur."); + } + break; + case INVALID: + if (receivingServer.strictCertificateValidation) { + expectedOutcome.set(NO_CONNECTION, "Initiating Entity provides an invalid TLS certificate, which should cause Receiving Entity to abort TLS (as per RFC 6120 section 13.7.2)."); + } else if (initiatingServer.dialbackSupported && receivingServer.dialbackSupported) { + expectedOutcome.set(ENCRYPTED_WITH_DIALBACK_AUTH, "Receiving Entity may choose to ignore Initiating Entities invalid certificate (for encryption purposes only) and choose to authenticate with Server Dialback (per RFC 7590 Section 3.4)"); + } else { + expectedOutcome.set(NO_CONNECTION, "Initiating Entity provides an invalid TLS certificate. As Server Dialback is not available, authentication cannot occur."); + } + break; + case VALID: + // valid certs all around. Dialback is not needed. + expectedOutcome.set(ENCRYPTED_WITH_SASLEXTERNAL_AUTH, "TLS is usable for both encryption and authentication."); + break; + } + break; + } + break; + } + break; + + case required: // <-- Initiating server's encryption policy. + switch (receivingServer.encryptionPolicy) { + case disabled: + expectedOutcome.set(NO_CONNECTION, "one peer requires encryption, the other disables encryption. This cannot work."); + break; + case optional: + switch (receivingServer.certificateState) { + case MISSING: + expectedOutcome.set(NO_CONNECTION, "Receiving Entity does not provide a TLS certificate. As ANON cypher suites are expected to be unavailable, the Initiating Entity cannot negotiate TLS and therefor the required encrypted connection cannot be established."); + // TODO: should we take into account a manual configuration of an ANON cypher suite, so that encryption-without-authentication can occur via TLS, followed by a Dialback-based authentication? + break; + case INVALID: + if (initiatingServer.strictCertificateValidation) { + expectedOutcome.set(NO_CONNECTION, "Receiving Entity provides an invalid TLS certificate, which should cause Initiating Entity to abort TLS (as per RFC 6120 section 13.7.2)."); + } else if (initiatingServer.dialbackSupported && receivingServer.dialbackSupported) { + expectedOutcome.set(ENCRYPTED_WITH_DIALBACK_AUTH, "Initiating Entity may choose to ignore Receiving Entities invalid certificate (for encryption purposes only) and choose to authenticate with Server Dialback (per RFC 7590 Section 3.4)"); + } else { + expectedOutcome.set(NO_CONNECTION, "Receiving Entity provides an invalid TLS certificate. As Server Dialback is not available, authentication cannot occur."); + } + break; + case VALID: + switch (initiatingServer.certificateState) { + case MISSING: + if (initiatingServer.dialbackSupported && receivingServer.dialbackSupported) { + expectedOutcome.set(ENCRYPTED_WITH_DIALBACK_AUTH, "Receiving Entity provides a valid TLS certificate allowing Initiating Entity to be able to establish TLS for encryption. Initiating Entity but does not provide a certificate itself, so SASL EXTERNAL is not available for Receiving Entity to authenticate Initiating Entity, but Dialback is available for that purpose."); + } else { + expectedOutcome.set(NO_CONNECTION, "Receiving Entity provides a valid TLS certificate allowing Initiating Entity to be able to establish TLS for encryption. Initiating Entity but does not provide a certificate itself, so SASL EXTERNAL is not available for Receiving Entity to authenticate Initiating Entity. As Dialback is also not available, authentication cannot occur."); + } + break; + case INVALID: + if (receivingServer.strictCertificateValidation) { + expectedOutcome.set(NO_CONNECTION, "Initiating Entity provides an invalid TLS certificate, which should cause Receiving Entity to abort TLS (as per RFC 6120 section 13.7.2)."); + } else if (initiatingServer.dialbackSupported && receivingServer.dialbackSupported) { + expectedOutcome.set(ENCRYPTED_WITH_DIALBACK_AUTH, "Receiving Entity may choose to ignore Initiating Entities invalid certificate (for encryption purposes only) and choose to authenticate with Server Dialback (per RFC 7590 Section 3.4)"); + } else { + expectedOutcome.set(NO_CONNECTION, "Initiating Entity provides an invalid TLS certificate. As Server Dialback is not available, authentication cannot occur."); + } + break; + case VALID: + // valid certs all around. Dialback is not needed. + expectedOutcome.set(ENCRYPTED_WITH_SASLEXTERNAL_AUTH, "TLS is usable for both encryption and authentication."); + break; + } + break; + } + break; + case required: + switch (receivingServer.certificateState) { + case MISSING: + expectedOutcome.set(NO_CONNECTION, "Receiving Entity does not provide a TLS certificate. As ANON cypher suites are expected to be unavailable, the Initiating Entity cannot negotiate TLS and therefor the required encrypted connection cannot be established."); + // TODO: should we take into account a manual configuration of an ANON cypher suite, so that encryption-without-authentication can occur via TLS, followed by a Dialback-based authentication? + break; + case INVALID: + if (initiatingServer.strictCertificateValidation) { + expectedOutcome.set(NO_CONNECTION, "Receiving Entity provides an invalid TLS certificate, which should cause Initiating Entity to abort TLS (as per RFC 6120 section 13.7.2)."); + } else if (initiatingServer.dialbackSupported && receivingServer.dialbackSupported) { + expectedOutcome.set(ENCRYPTED_WITH_DIALBACK_AUTH, "Initiating Entity may choose to ignore Receiving Entities invalid certificate (for encryption purposes only) and choose to authenticate with Server Dialback (per RFC 7590 Section 3.4)"); + } else { + expectedOutcome.set(NO_CONNECTION, "Receiving Entity provides an invalid TLS certificate. As Server Dialback is not available, authentication cannot occur."); + } + break; + case VALID: + switch (initiatingServer.certificateState) { + case MISSING: + if (initiatingServer.dialbackSupported && receivingServer.dialbackSupported) { + expectedOutcome.set(ENCRYPTED_WITH_DIALBACK_AUTH, "Initiating Entity can negotiate encryption, but does not provide a certificate. SASL EXTERNAL cannot be used, but Dialback is available, so authentication can occur."); + } else { + expectedOutcome.set(NO_CONNECTION, "Initiating Entity can negotiate encryption, but does not provide a certificate. As Dialback is not available, authentication cannot occur. Connection cannot be established."); + } + break; + case INVALID: + if (receivingServer.strictCertificateValidation) { + expectedOutcome.set(NO_CONNECTION, "Initiating Entity provides an invalid TLS certificate, which should cause Receiving Entity to abort TLS (as per RFC 6120 section 13.7.2)."); + } else if (initiatingServer.dialbackSupported && receivingServer.dialbackSupported) { + expectedOutcome.set(ENCRYPTED_WITH_DIALBACK_AUTH, "Receiving Entity may choose to ignore Initiating Entities invalid certificate (for encryption purposes only) and choose to authenticate with Server Dialback (per RFC 7590 Section 3.4)"); + } else { + expectedOutcome.set(NO_CONNECTION, "Initiating Entity provides an invalid TLS certificate. As Server Dialback is not available, authentication cannot occur."); + } + break; + case VALID: + expectedOutcome.set(ENCRYPTED_WITH_SASLEXTERNAL_AUTH, "Initiating Entity can establish encryption and authenticate using TLS."); + break; + } + break; + } + break; + } + break; + } + + // FIXME: add support for the DirectTLS TLS policy. + return expectedOutcome; + } +} diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/session/LocalIncomingServerSessionTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/session/LocalIncomingServerSessionTest.java new file mode 100644 index 0000000000..31cdf2004c --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/session/LocalIncomingServerSessionTest.java @@ -0,0 +1,338 @@ +/* + * Copyright (C) 2023 Ignite Realtime Foundation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.openfire.session; + +import org.jivesoftware.Fixtures; +import org.jivesoftware.openfire.Connection; +import org.jivesoftware.openfire.ConnectionManager; +import org.jivesoftware.openfire.SessionManager; +import org.jivesoftware.openfire.XMPPServer; +import org.jivesoftware.openfire.keystore.*; +import org.jivesoftware.openfire.net.BlockingAcceptingMode; +import org.jivesoftware.openfire.net.DNSUtil; +import org.jivesoftware.openfire.net.SocketAcceptThread; +import org.jivesoftware.openfire.net.SocketReader; +import org.jivesoftware.openfire.spi.ConnectionConfiguration; +import org.jivesoftware.openfire.spi.ConnectionListener; +import org.jivesoftware.openfire.spi.ConnectionType; +import org.jivesoftware.util.JiveGlobals; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.stubbing.Answer; + +import java.io.File; +import java.security.cert.X509Certificate; +import java.util.*; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Unit tests that verify if an inbound server-to-server socket connection can be created (and where applicable: + * encrypted and authenticated), verifying the implementation of {@link LocalIncomingServerSession} + * + * These tests assume the following constants: + * - TLS certificate validation is implemented correctly. + * - The domain name in the certificate matches that of the server. + * + * This implementation uses instances of {@link RemoteInitiatingServerDummy} to represent the remote server that + * initiates a server-to-server connection. + * + * @author Guus der Kinderen, guus@goodbytes.nl + * @author Alex Gidman, alex.gidman@surevine.com + */ +@ExtendWith(MockitoExtension.class) +public class LocalIncomingServerSessionTest +{ + private RemoteInitiatingServerDummy remoteInitiatingServerDummy; + private File tmpIdentityStoreFile; + private IdentityStore identityStore; + private File tmpTrustStoreFile; + private TrustStore trustStore; + + /** + * Prepares the local server for operation. This mostly involves preparing the test fixture by mocking parts of the + * API that {@link LocalOutgoingServerSession#createOutgoingSession(DomainPair, int)} uses when establishing a + * connection. + */ + @BeforeEach + public void setUpClass() throws Exception { + Fixtures.reconfigureOpenfireHome(); + JiveGlobals.setProperty("xmpp.domain", Fixtures.XMPP_DOMAIN); + final XMPPServer xmppServer = Fixtures.mockXMPPServer(); + XMPPServer.setInstance(xmppServer); + + final File tmpDir = new File(System.getProperty("java.io.tmpdir")); + + // Use a temporary file to hold the identity store that is used by the tests. + final CertificateStoreManager certificateStoreManager = mock(CertificateStoreManager.class, withSettings().lenient()); + tmpIdentityStoreFile = new File(tmpDir, "unittest-identitystore-" + System.currentTimeMillis() + ".jks"); + tmpIdentityStoreFile.deleteOnExit(); + final CertificateStoreConfiguration identityStoreConfig = new CertificateStoreConfiguration("jks", tmpIdentityStoreFile, "secret".toCharArray(), tmpDir); + identityStore = new IdentityStore(identityStoreConfig, true); + doReturn(identityStore).when(certificateStoreManager).getIdentityStore(any()); + + // Use a temporary file to hold the trust store that is used by the tests. + tmpTrustStoreFile = new File(tmpDir, "unittest-truststore-" + System.currentTimeMillis() + ".jks"); + tmpTrustStoreFile.deleteOnExit(); + final CertificateStoreConfiguration trustStoreConfig = new CertificateStoreConfiguration("jks", tmpTrustStoreFile, "secret".toCharArray(), tmpDir); + trustStore = new TrustStore(trustStoreConfig, true); + doReturn(trustStore).when(certificateStoreManager).getTrustStore(any()); + + // Mock the connection configuration. + doReturn(certificateStoreManager).when(xmppServer).getCertificateStoreManager(); + + final SessionManager sessionManager = new SessionManager(); // This is the system under test. We do not want to use a mock for this test! + sessionManager.initialize(xmppServer); + doReturn(sessionManager).when(xmppServer).getSessionManager(); + + final ConnectionManager connectionManager = Fixtures.mockConnectionManager(); + final ConnectionListener connectionListener = Fixtures.mockConnectionListener(); + doAnswer(new ConnectionConfigurationAnswer(identityStoreConfig, trustStoreConfig)).when(connectionListener).generateConnectionConfiguration(); + doReturn(Set.of(connectionListener)).when(connectionManager).getListeners(any(ConnectionType.class)); + doReturn(connectionListener).when(connectionManager).getListener(any(ConnectionType.class), anyBoolean()); + doReturn(connectionManager).when(xmppServer).getConnectionManager(); + setUp(); + } + + /** + * Dynamically generate a ConnectionConfiguration answer, as used by the Mock ConnectionListener. + *

+ * A dynamic answer is needed, as the value of the ConnectionSettings.Server.TLS_POLICY property needs to be + * evaluated at run-time (this value is changed in the setup of many of the unit tests in this file). + *

+ */ + private static class ConnectionConfigurationAnswer implements Answer { + + private CertificateStoreConfiguration identityStoreConfig; + private CertificateStoreConfiguration trustStoreConfig; + + private ConnectionConfigurationAnswer(CertificateStoreConfiguration identityStoreConfig, CertificateStoreConfiguration trustStoreConfig) + { + this.identityStoreConfig = identityStoreConfig; + this.trustStoreConfig = trustStoreConfig; + } + + @Override + public Object answer(InvocationOnMock invocation) throws Throwable + { + final Connection.TLSPolicy tlsPolicy = Connection.TLSPolicy.valueOf(JiveGlobals.getProperty(ConnectionSettings.Server.TLS_POLICY, Connection.TLSPolicy.optional.toString())); + final Set suites = Set.of("TLS_AES_256_GCM_SHA384","TLS_AES_128_GCM_SHA256","TLS_CHACHA20_POLY1305_SHA256","TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384","TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256","TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256","TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384","TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256","TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256","TLS_DHE_RSA_WITH_AES_256_GCM_SHA384","TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256","TLS_DHE_DSS_WITH_AES_256_GCM_SHA384","TLS_DHE_RSA_WITH_AES_128_GCM_SHA256","TLS_DHE_DSS_WITH_AES_128_GCM_SHA256","TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384","TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384","TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256","TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256","TLS_DHE_RSA_WITH_AES_256_CBC_SHA256","TLS_DHE_DSS_WITH_AES_256_CBC_SHA256","TLS_DHE_RSA_WITH_AES_128_CBC_SHA256","TLS_DHE_DSS_WITH_AES_128_CBC_SHA256","TLS_ECDH_ECDSA_WITH_AES_256_GCM_SHA384","TLS_ECDH_RSA_WITH_AES_256_GCM_SHA384","TLS_ECDH_ECDSA_WITH_AES_128_GCM_SHA256","TLS_ECDH_RSA_WITH_AES_128_GCM_SHA256","TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA384","TLS_ECDH_RSA_WITH_AES_256_CBC_SHA384","TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA256","TLS_ECDH_RSA_WITH_AES_128_CBC_SHA256","TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA","TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA","TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA","TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA","TLS_DHE_RSA_WITH_AES_256_CBC_SHA","TLS_DHE_DSS_WITH_AES_256_CBC_SHA","TLS_DHE_RSA_WITH_AES_128_CBC_SHA","TLS_DHE_DSS_WITH_AES_128_CBC_SHA","TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA","TLS_ECDH_RSA_WITH_AES_256_CBC_SHA","TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA","TLS_ECDH_RSA_WITH_AES_128_CBC_SHA","TLS_RSA_WITH_AES_256_GCM_SHA384","TLS_RSA_WITH_AES_128_GCM_SHA256","TLS_RSA_WITH_AES_256_CBC_SHA256","TLS_RSA_WITH_AES_128_CBC_SHA256","TLS_RSA_WITH_AES_256_CBC_SHA","TLS_RSA_WITH_AES_128_CBC_SHA","TLS_EMPTY_RENEGOTIATION_INFO_SCSV"); + final Set protocols = Set.of("TLSv1.2"); + return new ConnectionConfiguration(ConnectionType.SOCKET_S2S, true, 10, -1, Connection.ClientAuth.wanted, null, 9999, tlsPolicy, identityStoreConfig, trustStoreConfig, true, true, protocols, suites, Connection.CompressionPolicy.optional, true ); + } + } + + public void setUp() throws Exception + { + remoteInitiatingServerDummy = new RemoteInitiatingServerDummy(Fixtures.XMPP_DOMAIN); + } + + @AfterEach + public void tearDown() throws Exception + { + tmpIdentityStoreFile.delete(); + tmpTrustStoreFile.delete(); + identityStore = null; + trustStore = null; + DNSUtil.setDnsOverride(null); + + if (remoteInitiatingServerDummy != null) { + remoteInitiatingServerDummy.disconnect(); + remoteInitiatingServerDummy = null; + } + + Fixtures.clearExistingProperties(); + } + + /** + * Unit test in which Openfire reacts to an inbound server-to-server connection attempt. + * + * This test is parameterized, meaning that the configuration of both the local server and the remote mock server is + * passed as an argument to this method. These configurations are used to both initialize and execute the test, but + * also to calculate the expected outcome of the test, given the provided configuration. This expected outcome is + * asserted by the test implementation + * + * @param localServerSettings Server settings for the system under test (the 'local' server). + * @param remoteServerSettings Server settings for the mock server that is used as a peer in this test. + */ + @ParameterizedTest + @MethodSource("arguments") + public void incomingTest(final ServerSettings localServerSettings, final ServerSettings remoteServerSettings) + throws Exception + { + final ExpectedOutcome expected = ExpectedOutcome.generateExpectedOutcome(remoteServerSettings, localServerSettings); + if (RemoteInitiatingServerDummy.doLog) System.out.println("Executing test:\n - Local Server, Recipient, System Under Test Settings: " + localServerSettings + "\n - Remote Server, Initiator, dummy/mock server Settings: " + remoteServerSettings + "\nExpected outcome: " + expected.getConnectionState()); + + JiveGlobals.setProperty("xmpp.domain", Fixtures.XMPP_DOMAIN); + final TrustStore trustStore = XMPPServer.getInstance().getCertificateStoreManager().getTrustStore(ConnectionType.SOCKET_S2S); + final IdentityStore identityStore = XMPPServer.getInstance().getCertificateStoreManager().getIdentityStore(ConnectionType.SOCKET_S2S); + try { + // Setup test fixture. + + // Remote server TLS policy. + remoteInitiatingServerDummy.setEncryptionPolicy(remoteServerSettings.encryptionPolicy); + + // Remote server dialback + remoteInitiatingServerDummy.setDisableDialback(!remoteServerSettings.dialbackSupported); + + // Remote server certificate state + switch (remoteServerSettings.certificateState) { + case INVALID: + remoteInitiatingServerDummy.setUseExpiredEndEntityCertificate(true); + // Intended fall-through + case VALID: + remoteInitiatingServerDummy.preparePKIX(); + + // Install in local server's truststore. + final X509Certificate[] chain = remoteInitiatingServerDummy.getGeneratedPKIX().getCertificateChain(); + final X509Certificate caCert = chain[chain.length-1]; + trustStore.installCertificate("unit-test", KeystoreTestUtils.toPemFormat(caCert)); + break; + case MISSING: + break; + default: + throw new IllegalStateException("Unsupported remote certificate state"); + } + + // Local server TLS policy. + JiveGlobals.setProperty(ConnectionSettings.Server.TLS_POLICY, localServerSettings.encryptionPolicy.toString()); + + // Local server dialback. + JiveGlobals.setProperty(ConnectionSettings.Server.DIALBACK_ENABLED, localServerSettings.dialbackSupported ? "true" : "false"); + + // Local server certificate state + switch (localServerSettings.certificateState) { + case MISSING: + // Do not install domain certificate. + break; + case INVALID: + // Insert an expired certificate into the identity store + identityStore.installCertificate(Fixtures.expiredX509Certificate, Fixtures.privateKeyForExpiredCert, ""); + break; + case VALID: + // Generate a valid certificate and insert into identity store + identityStore.ensureDomainCertificate(); + break; + } + + remoteInitiatingServerDummy.init(); + if (remoteInitiatingServerDummy.getDialbackAuthoritativeServerPort() > 0) { + DNSUtil.setDnsOverride(Map.of(RemoteInitiatingServerDummy.XMPP_DOMAIN, new DNSUtil.HostAddress("localhost", remoteInitiatingServerDummy.getDialbackAuthoritativeServerPort(), false))); + } + + // execute system under test. + final SocketAcceptThread socketAcceptThread = new SocketAcceptThread(0, null, false); + socketAcceptThread.setDaemon(true); + socketAcceptThread.setPriority(Thread.MAX_PRIORITY); + socketAcceptThread.start(); + + // now, make the remote server connect. + remoteInitiatingServerDummy.connect(socketAcceptThread.getPort()); + remoteInitiatingServerDummy.blockUntilDone(1, TimeUnit.MINUTES); + + // get the incoming server session object. + final LocalIncomingServerSession result; + BlockingAcceptingMode mode = ((BlockingAcceptingMode) socketAcceptThread.getAcceptingMode()); + final SocketReader socketReader = mode == null ? null : mode.getLastReader(); + result = socketReader == null ? null : (LocalIncomingServerSession) socketReader.getSession(); + + // Verify results + if (RemoteInitiatingServerDummy.doLog) System.out.println("Expect: " + expected.getConnectionState() + ", Result: " + result); + switch (expected.getConnectionState()) + { + case NO_CONNECTION: + if (result == null) { + assertNull(result); // Yes, this is silly. + } else { + assertFalse(result.isAuthenticated()); + } + break; + case NON_ENCRYPTED_WITH_DIALBACK_AUTH: + assertNotNull(result); + assertFalse(result.isClosed()); + assertFalse(result.isEncrypted()); + assertTrue(result.isAuthenticated()); + assertEquals(ServerSession.AuthenticationMethod.DIALBACK, result.getAuthenticationMethod()); + break; + case ENCRYPTED_WITH_DIALBACK_AUTH: + assertNotNull(result); + assertFalse(result.isClosed()); + assertTrue(result.isEncrypted()); + assertTrue(result.isAuthenticated()); + assertEquals(ServerSession.AuthenticationMethod.DIALBACK, result.getAuthenticationMethod()); + break; + case ENCRYPTED_WITH_SASLEXTERNAL_AUTH: + assertNotNull(result); + assertFalse(result.isClosed()); + assertTrue(result.isEncrypted()); + assertTrue(result.isAuthenticated()); + assertEquals(ServerSession.AuthenticationMethod.SASL_EXTERNAL, result.getAuthenticationMethod()); + break; + } + if (RemoteInitiatingServerDummy.doLog) System.out.println("Expectation met."); + } finally { + // Teardown test fixture. + trustStore.delete("unit-test"); + } + } + + /** + * Provides the arguments for the method that implements the unit test. + * @return Unit test arguments + */ + private static Iterable arguments() { + final Collection result = new LinkedList<>(); + + final Set localServerSettings = new LinkedHashSet<>(); + final Set remoteServerSettings = new LinkedHashSet<>(); + + for (final ServerSettings.CertificateState certificateState : ServerSettings.CertificateState.values()) { + for (final boolean dialbackSupported : List.of(true, false)) { + for (final Connection.TLSPolicy tlsPolicy : Connection.TLSPolicy.values()) { + if (tlsPolicy == Connection.TLSPolicy.legacyMode) { + continue; // TODO add support for DirectTLS in this unit test! + } + final ServerSettings serverSettings = new ServerSettings(tlsPolicy, certificateState,true, dialbackSupported); // TODO add support for both strict certificate validation settings. + localServerSettings.add(serverSettings); + remoteServerSettings.add(serverSettings); + } + } + } + + for (final ServerSettings local : localServerSettings) { + for (final ServerSettings remote : remoteServerSettings) { + result.add(Arguments.arguments(local, remote)); + } + } + + // Not all test-runners easily identify the parameters that are used to run each test iteration. Those that do + // not, typically show a number. By outputting the numbered arguments, they can be cross-referenced with any + // failed test case. + int i = 1; + for (Arguments arguments : result) { + System.out.println("Test [" + i++ + "]: " + arguments.get()[0] + ", " + arguments.get()[1]); + } + return result; + } +} diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/session/LocalOutgoingServerSessionTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/session/LocalOutgoingServerSessionTest.java new file mode 100644 index 0000000000..947ad9989c --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/session/LocalOutgoingServerSessionTest.java @@ -0,0 +1,319 @@ +/* + * Copyright (C) 2023 Ignite Realtime Foundation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.openfire.session; + +import org.jivesoftware.Fixtures; +import org.jivesoftware.openfire.*; +import org.jivesoftware.openfire.keystore.*; +import org.jivesoftware.openfire.net.*; +import org.jivesoftware.openfire.spi.ConnectionConfiguration; +import org.jivesoftware.openfire.spi.ConnectionListener; +import org.jivesoftware.openfire.spi.ConnectionType; +import org.jivesoftware.util.JiveGlobals; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.stubbing.Answer; + +import java.io.File; +import java.security.cert.X509Certificate; +import java.util.*; + +import static org.jivesoftware.openfire.session.ExpectedOutcome.ConnectionState.NON_ENCRYPTED_WITH_DIALBACK_AUTH; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Unit tests that verify if an outbound server-to-server socket connection can be created (and where applicable: + * encrypted and authenticated), verifying the implementation of {@link LocalOutgoingServerSession#createOutgoingSession(DomainPair, int)} + * + * These tests assume the following constants: + * - TLS certificate validation is implemented correctly. + * - The domain name in the certificate matches that of the server. + * + * This implementation uses instances of {@link RemoteReceivingServerDummy} to represent the remote server to which a connection + * is being made. + * + * @author Guus der Kinderen, guus@goodbytes.nl + * @author Alex Gidman, alex.gidman@surevine.com + */ +@ExtendWith(MockitoExtension.class) +public class LocalOutgoingServerSessionTest +{ + private RemoteReceivingServerDummy remoteReceivingServerDummy; + private File tmpIdentityStoreFile; + private IdentityStore identityStore; + private File tmpTrustStoreFile; + private TrustStore trustStore; + + /** + * Prepares the local server for operation. This mostly involves preparing the test fixture by mocking parts of the + * API that {@link LocalOutgoingServerSession#createOutgoingSession(DomainPair, int)} uses when establishing a + * connection. + */ + @BeforeEach + public void setUpClass() throws Exception { + Fixtures.reconfigureOpenfireHome(); + JiveGlobals.setProperty("xmpp.domain", Fixtures.XMPP_DOMAIN); + final XMPPServer xmppServer = Fixtures.mockXMPPServer(); + XMPPServer.setInstance(xmppServer); + + final File tmpDir = new File(System.getProperty("java.io.tmpdir")); + + // Use a temporary file to hold the identity store that is used by the tests. + final CertificateStoreManager certificateStoreManager = mock(CertificateStoreManager.class, withSettings().lenient()); + tmpIdentityStoreFile = new File(tmpDir, "unittest-identitystore-" + System.currentTimeMillis() + ".jks"); + tmpIdentityStoreFile.deleteOnExit(); + final CertificateStoreConfiguration identityStoreConfig = new CertificateStoreConfiguration("jks", tmpIdentityStoreFile, "secret".toCharArray(), tmpDir); + identityStore = new IdentityStore(identityStoreConfig, true); + doReturn(identityStore).when(certificateStoreManager).getIdentityStore(any()); + + // Use a temporary file to hold the trust store that is used by the tests. + tmpTrustStoreFile = new File(tmpDir, "unittest-truststore-" + System.currentTimeMillis() + ".jks"); + tmpTrustStoreFile.deleteOnExit(); + final CertificateStoreConfiguration trustStoreConfig = new CertificateStoreConfiguration("jks", tmpTrustStoreFile, "secret".toCharArray(), tmpDir); + trustStore = new TrustStore(trustStoreConfig, true); + doReturn(trustStore).when(certificateStoreManager).getTrustStore(any()); + + // Mock the connection configuration. + doReturn(certificateStoreManager).when(xmppServer).getCertificateStoreManager(); + + final ConnectionManager connectionManager = Fixtures.mockConnectionManager(); + final ConnectionListener connectionListener = Fixtures.mockConnectionListener(); + doAnswer(new ConnectionConfigurationAnswer(identityStoreConfig, trustStoreConfig)).when(connectionListener).generateConnectionConfiguration(); + doReturn(Set.of(connectionListener)).when(connectionManager).getListeners(any(ConnectionType.class)); + doReturn(connectionListener).when(connectionManager).getListener(any(ConnectionType.class), anyBoolean()); + doReturn(connectionManager).when(xmppServer).getConnectionManager(); + setUp(); + } + + /** + * Dynamically generate a ConnectionConfiguration answer, as used by the Mock ConnectionListener. + *

+ * A dynamic answer is needed, as the value of the ConnectionSettings.Server.TLS_POLICY property needs to be + * evaluated at run-time (this value is changed in the setup of many of the unit tests in this file). + *

+ */ + private static class ConnectionConfigurationAnswer implements Answer { + + private CertificateStoreConfiguration identityStoreConfig; + private CertificateStoreConfiguration trustStoreConfig; + + private ConnectionConfigurationAnswer(CertificateStoreConfiguration identityStoreConfig, CertificateStoreConfiguration trustStoreConfig) + { + this.identityStoreConfig = identityStoreConfig; + this.trustStoreConfig = trustStoreConfig; + } + + @Override + public Object answer(InvocationOnMock invocation) throws Throwable + { + final Connection.TLSPolicy tlsPolicy = Connection.TLSPolicy.valueOf(JiveGlobals.getProperty(ConnectionSettings.Server.TLS_POLICY, Connection.TLSPolicy.optional.toString())); + final Set suites = Set.of("TLS_AES_256_GCM_SHA384","TLS_AES_128_GCM_SHA256","TLS_CHACHA20_POLY1305_SHA256","TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384","TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256","TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256","TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384","TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256","TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256","TLS_DHE_RSA_WITH_AES_256_GCM_SHA384","TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256","TLS_DHE_DSS_WITH_AES_256_GCM_SHA384","TLS_DHE_RSA_WITH_AES_128_GCM_SHA256","TLS_DHE_DSS_WITH_AES_128_GCM_SHA256","TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384","TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384","TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256","TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256","TLS_DHE_RSA_WITH_AES_256_CBC_SHA256","TLS_DHE_DSS_WITH_AES_256_CBC_SHA256","TLS_DHE_RSA_WITH_AES_128_CBC_SHA256","TLS_DHE_DSS_WITH_AES_128_CBC_SHA256","TLS_ECDH_ECDSA_WITH_AES_256_GCM_SHA384","TLS_ECDH_RSA_WITH_AES_256_GCM_SHA384","TLS_ECDH_ECDSA_WITH_AES_128_GCM_SHA256","TLS_ECDH_RSA_WITH_AES_128_GCM_SHA256","TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA384","TLS_ECDH_RSA_WITH_AES_256_CBC_SHA384","TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA256","TLS_ECDH_RSA_WITH_AES_128_CBC_SHA256","TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA","TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA","TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA","TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA","TLS_DHE_RSA_WITH_AES_256_CBC_SHA","TLS_DHE_DSS_WITH_AES_256_CBC_SHA","TLS_DHE_RSA_WITH_AES_128_CBC_SHA","TLS_DHE_DSS_WITH_AES_128_CBC_SHA","TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA","TLS_ECDH_RSA_WITH_AES_256_CBC_SHA","TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA","TLS_ECDH_RSA_WITH_AES_128_CBC_SHA","TLS_RSA_WITH_AES_256_GCM_SHA384","TLS_RSA_WITH_AES_128_GCM_SHA256","TLS_RSA_WITH_AES_256_CBC_SHA256","TLS_RSA_WITH_AES_128_CBC_SHA256","TLS_RSA_WITH_AES_256_CBC_SHA","TLS_RSA_WITH_AES_128_CBC_SHA","TLS_EMPTY_RENEGOTIATION_INFO_SCSV"); + final Set protocols = Set.of("TLSv1.2"); + return new ConnectionConfiguration(ConnectionType.SOCKET_S2S, true, 10, -1, Connection.ClientAuth.wanted, null, 9999, tlsPolicy, identityStoreConfig, trustStoreConfig, true, true, protocols, suites, Connection.CompressionPolicy.optional, true ); + } + } + + public void setUp() throws Exception + { + remoteReceivingServerDummy = new RemoteReceivingServerDummy(); + remoteReceivingServerDummy.open(); + DNSUtil.setDnsOverride(Map.of(RemoteReceivingServerDummy.XMPP_DOMAIN, new DNSUtil.HostAddress("localhost", remoteReceivingServerDummy.getPort(), false))); + } + + @AfterEach + public void tearDown() throws Exception + { + tmpIdentityStoreFile.delete(); + tmpTrustStoreFile.delete(); + identityStore = null; + trustStore = null; + DNSUtil.setDnsOverride(null); + + if (remoteReceivingServerDummy != null) { + remoteReceivingServerDummy.close(); + remoteReceivingServerDummy = null; + } + + Fixtures.clearExistingProperties(); + } + + /** + * Unit test in which Openfire initiates an outgoing server-to-server connection. + * + * This test is parameterized, meaning that the configuration of both the local server and the remote mock server is + * passed as an argument to this method. These configurations are used to both initialize and execute the test, but + * also to calculate the expected outcome of the test, given the provided configuration. This expected outcome is + * asserted by the test implementation + * + * @param localServerSettings Server settings for the system under test (the 'local' server). + * @param remoteServerSettings Server settings for the mock server that is used as a peer in this test. + */ + @ParameterizedTest + @MethodSource("arguments") + public void outgoingTest(final ServerSettings localServerSettings, final ServerSettings remoteServerSettings) + throws Exception + { + final ExpectedOutcome expected; + if (localServerSettings.encryptionPolicy == Connection.TLSPolicy.optional && localServerSettings.certificateState == ServerSettings.CertificateState.INVALID && localServerSettings.dialbackSupported && + remoteServerSettings.encryptionPolicy == Connection.TLSPolicy.optional && remoteServerSettings.certificateState == ServerSettings.CertificateState.VALID && remoteServerSettings.dialbackSupported) { + // TODO: can we improve on this test to explicitly verify the outcome if the first connection? + expected = new ExpectedOutcome(); + expected.set(NON_ENCRYPTED_WITH_DIALBACK_AUTH, "For this very specific configuration, the expected outcome is 'NO_CONNECTION'. However, Openfire will (and should) immediately make a new connection attempt (without TLS), that SHOULD succeed."); + } else { + expected = ExpectedOutcome.generateExpectedOutcome(localServerSettings, remoteServerSettings); + } + if (RemoteReceivingServerDummy.doLog) System.out.println("Executing test:\n - Local Server (Openfire, System under test) Settings: " + localServerSettings + "\n - Remote Server (dummy/mock server) Settings: " + remoteServerSettings + "\nExpected outcome: " + expected.getConnectionState()); + + JiveGlobals.setProperty("xmpp.domain", Fixtures.XMPP_DOMAIN); + final TrustStore trustStore = XMPPServer.getInstance().getCertificateStoreManager().getTrustStore(ConnectionType.SOCKET_S2S); + final IdentityStore identityStore = XMPPServer.getInstance().getCertificateStoreManager().getIdentityStore(ConnectionType.SOCKET_S2S); + + try { + // Setup test fixture. + + // Remote server TLS policy. + remoteReceivingServerDummy.setEncryptionPolicy(remoteServerSettings.encryptionPolicy); + + // Remote server dialback + remoteReceivingServerDummy.setDisableDialback(!remoteServerSettings.dialbackSupported); + + // Remote server certificate state + switch (remoteServerSettings.certificateState) { + case INVALID: + remoteReceivingServerDummy.setUseExpiredEndEntityCertificate(true); + // Intended fall-through + case VALID: + remoteReceivingServerDummy.preparePKIX(); + + // Install in local server's truststore. + final X509Certificate[] chain = remoteReceivingServerDummy.getGeneratedPKIX().getCertificateChain(); + final X509Certificate caCert = chain[chain.length-1]; + trustStore.installCertificate("unit-test", KeystoreTestUtils.toPemFormat(caCert)); + break; + case MISSING: + break; + default: + throw new IllegalStateException("Unsupported remote certificate state"); + } + + // Local server TLS policy. + JiveGlobals.setProperty(ConnectionSettings.Server.TLS_POLICY, localServerSettings.encryptionPolicy.toString()); + + // Local server dialback. + JiveGlobals.setProperty(ConnectionSettings.Server.DIALBACK_ENABLED, localServerSettings.dialbackSupported ? "true" : "false"); + + // Local server certificate state + switch (localServerSettings.certificateState) { + case MISSING: + // Do not install domain certificate. + break; + case INVALID: + // Insert an expired certificate into the identity store + identityStore.installCertificate(Fixtures.expiredX509Certificate, Fixtures.privateKeyForExpiredCert, ""); + break; + case VALID: + // Generate a valid certificate and insert into identity store + identityStore.ensureDomainCertificate(); + break; + } + + final DomainPair domainPair = new DomainPair(Fixtures.XMPP_DOMAIN, RemoteReceivingServerDummy.XMPP_DOMAIN); + final int port = remoteReceivingServerDummy.getPort(); + + // Execute system under test. + final LocalOutgoingServerSession result = LocalOutgoingServerSession.createOutgoingSession(domainPair, port); + + // Verify results + if (RemoteReceivingServerDummy.doLog) System.out.println("Expect: " + expected.getConnectionState() + ", Result: " + result); + switch (expected.getConnectionState()) + { + case NO_CONNECTION: + assertNull(result); + break; + case NON_ENCRYPTED_WITH_DIALBACK_AUTH: + assertNotNull(result); + assertFalse(result.isClosed()); + assertFalse(result.isEncrypted()); + assertTrue(result.isAuthenticated()); + assertEquals(ServerSession.AuthenticationMethod.DIALBACK, result.getAuthenticationMethod()); + break; + case ENCRYPTED_WITH_DIALBACK_AUTH: + assertNotNull(result); + assertFalse(result.isClosed()); + assertTrue(result.isEncrypted()); + assertTrue(result.isAuthenticated()); + assertEquals(ServerSession.AuthenticationMethod.DIALBACK, result.getAuthenticationMethod()); + break; + case ENCRYPTED_WITH_SASLEXTERNAL_AUTH: + assertNotNull(result); + assertFalse(result.isClosed()); + assertTrue(result.isEncrypted()); + assertTrue(result.isAuthenticated()); + assertEquals(ServerSession.AuthenticationMethod.SASL_EXTERNAL, result.getAuthenticationMethod()); + break; + } + } finally { + // Teardown test fixture. + trustStore.delete("unit-test"); + } + } + + /** + * Provides the arguments for the method that implements the unit test. + * @return Unit test arguments + */ + private static Iterable arguments() { + final Collection result = new LinkedList<>(); + + final Set localServerSettings = new LinkedHashSet<>(); + final Set remoteServerSettings = new LinkedHashSet<>(); + + for (final ServerSettings.CertificateState certificateState : ServerSettings.CertificateState.values()) { + for (final boolean dialbackSupported : List.of(true, false)) { + for (final Connection.TLSPolicy tlsPolicy : Connection.TLSPolicy.values()) { + if (tlsPolicy == Connection.TLSPolicy.legacyMode) { + continue; // TODO add support for DirectTLS in this unit test! + } + final ServerSettings serverSettings = new ServerSettings(tlsPolicy, certificateState, true, dialbackSupported); // TODO add support for both strict certificate validation settings. + localServerSettings.add(serverSettings); + remoteServerSettings.add(serverSettings); + } + } + } + + for (final ServerSettings local : localServerSettings) { + for (final ServerSettings remote : remoteServerSettings) { + result.add(Arguments.arguments(local, remote)); + } + } + + // Not all test-runners easily identify the parameters that are used to run each test iteration. Those that do + // not, typically show a number. By outputting the numbered arguments, they can be cross-referenced with any + // failed test case. + int i = 1; + for (Arguments arguments : result) { + System.out.println("Test [" + i++ + "]: " + arguments.get()[0] + ", " + arguments.get()[1]); + } + return result; + } +} diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/session/RemoteInitiatingServerDummy.java b/xmppserver/src/test/java/org/jivesoftware/openfire/session/RemoteInitiatingServerDummy.java new file mode 100644 index 0000000000..7614839b0b --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/session/RemoteInitiatingServerDummy.java @@ -0,0 +1,555 @@ +package org.jivesoftware.openfire.session; + +import org.dom4j.*; +import org.jivesoftware.openfire.Connection; +import org.jivesoftware.util.Base64; + +import javax.net.ssl.*; +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.io.OutputStream; +import java.net.*; +import java.security.KeyManagementException; +import java.security.KeyPair; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.Arrays; +import java.util.concurrent.*; + +public class RemoteInitiatingServerDummy extends AbstractRemoteServerDummy +{ + /** + * When switched to 'true', most XMPP interaction will be printed to standard-out. + */ + public static final boolean doLog = false; + + private ServerSocket dialbackAuthoritativeServer; + private Thread dialbackAcceptThread; + private DialbackAcceptor dialbackAcceptor = new DialbackAcceptor(); + + private final String connectTo; + boolean attemptedEncryptionNegotiation = false; + boolean alreadyTriedSaslExternal = false; + boolean peerSupportsDialback; + private ExecutorService processingService; + + /** + * A monitor that is used to flag when this dummy has finished trying to set up a connection to Openfire. This is to + * help the unit test know when it can start verifying the test outcome. + */ + private final Phaser phaser = new Phaser(0); + + public RemoteInitiatingServerDummy(final String connectTo) + { + this.connectTo = connectTo; + } + + public void init() throws IOException + { + if (!disableDialback) { + dialbackAuthoritativeServer = new ServerSocket(0); + } + } + + public void connect(int port) throws IOException, InterruptedException + { + if (doLog) System.out.println("connect"); + processingService = Executors.newCachedThreadPool(); + + if (dialbackAuthoritativeServer != null) { + dialbackAcceptThread = new Thread(dialbackAcceptor); + dialbackAcceptThread.start(); + } + + phaser.register(); + + final SocketProcessor socketProcessor = new SocketProcessor(port); + processingService.submit(socketProcessor); + } + + public void blockUntilDone(final long timeout, final TimeUnit unit) { + try { + phaser.awaitAdvanceInterruptibly(0, timeout, unit); + } catch (InterruptedException | TimeoutException e) { + throw new RuntimeException("Test scenario never reached 'done' state"); + } + } + + protected void done() { + phaser.arriveAndDeregister(); + } + + public void disconnect() throws InterruptedException, IOException + { + if (doLog) System.out.println("disconnect"); + stopProcessingService(); + stopDialbackAcceptThread(); + if (dialbackAuthoritativeServer != null) { + dialbackAuthoritativeServer.close(); + dialbackAuthoritativeServer = null; + } + } + + public synchronized void stopProcessingService() throws InterruptedException + { + processingService.shutdown(); + final Instant end = Instant.now().plus(SO_TIMEOUT.multipliedBy(20)); + while (Instant.now().isBefore(end) && !processingService.isTerminated()) { + Thread.sleep(Math.max(100, SO_TIMEOUT.dividedBy(50).toMillis())); + } + if (!processingService.isTerminated()) { + processingService.shutdownNow(); + } + } + + public synchronized void stopDialbackAcceptThread() throws InterruptedException + { + if (dialbackAcceptThread == null) { + return; + } + dialbackAcceptor.stop(); + dialbackAcceptThread.interrupt(); + final Instant end = Instant.now().plus(SO_TIMEOUT.multipliedBy(20)); + while (Instant.now().isBefore(end) && dialbackAcceptThread.getState() != Thread.State.TERMINATED) { + Thread.sleep(Math.max(10, SO_TIMEOUT.dividedBy(50).toMillis())); + } + final Thread.State finalState = dialbackAcceptThread.getState(); + if (finalState != Thread.State.TERMINATED) { + if (doLog) System.err.println("Dialback Accept thread not terminating after it was stopped. Current state: " + finalState); + if (doLog) Arrays.stream(dialbackAcceptThread.getStackTrace()).forEach(System.err::println); + dialbackAcceptThread.stop(); + } + dialbackAcceptThread = null; + } + + public int getDialbackAuthoritativeServerPort() + { + return dialbackAuthoritativeServer != null ? dialbackAuthoritativeServer.getLocalPort() : -1; + } + + private class DialbackAcceptor implements Runnable + { + boolean shouldStop = false; + + void stop() { + shouldStop = true; + } + + @Override + public void run() + { + if (doLog) System.out.println("Start accepting socket connections (as Server Dialback Authoritative Server)."); + while (!shouldStop) { + try { + dialbackAuthoritativeServer.setSoTimeout((int)SO_TIMEOUT.multipliedBy(10).toMillis()); + final Socket socket = dialbackAuthoritativeServer.accept(); + final InputStream is = socket.getInputStream(); + final OutputStream os = socket.getOutputStream(); + if (doLog) System.out.println("DIALBACK AUTH SERVER: Accepted new socket connection."); + + Thread.sleep(100); + final byte[] buffer = new byte[1024 * 16]; + int count; + while ((count = is.read(buffer)) > 0) { + String read = new String(buffer, 0, count); + if (doLog) System.out.println("# DIALBACK AUTH SERVER recv"); + if (doLog) System.out.println(read); + if (doLog) System.out.println(); + + final Document outbound = DocumentHelper.createDocument(); + final Namespace namespace = new Namespace("stream", "http://etherx.jabber.org/streams"); + final Namespace dialbackNamespace = new Namespace("db", "jabber:server:dialback"); + final Element stream = outbound.addElement(QName.get("stream", namespace)); + stream.add(Namespace.get("jabber:server")); + stream.add(dialbackNamespace); + stream.addAttribute("from", XMPP_DOMAIN); + stream.addAttribute("to", connectTo); + stream.addAttribute("version", "1.0"); + + String response = null; + if (read.startsWith("")); + } else if (read.startsWith("")) { + response = ""; + } else { + if (doLog) System.out.println("I don't know how to process this data."); + } + + if (response != null) { + if (doLog) System.out.println("# DIALBACK AUTH SERVER send to Openfire"); + if (doLog) System.out.println(response); + if (doLog) System.out.println(); + os.write(response.getBytes()); + os.flush(); + + if (response.equals("")) { + socket.close(); + break; + } + } + } + } catch (Throwable t) { + // Log exception only when not cleanly closed. + if (dialbackAcceptThread != null && !dialbackAcceptThread.isInterrupted()) { + if (!(t instanceof SocketTimeoutException) && !shouldStop) { + t.printStackTrace(); + } + } else { + break; + } + } + } + if (doLog) System.out.println("Stopped accepting socket connections (as Server Dialback Authoritative Server)."); + } + } + + private class SocketProcessor implements Runnable + { + private Socket socket; + private OutputStream os; + private InputStream is; + boolean peerAdvertisedDialbackNamespace = false; + + /** + * To speed up the test execution, SO_TIMEOUT (the socket read timeout) has been set to a low value. When negotiating Server Dialback, a second + * socket connection is used. XMPP session establishment on the first socket connection is paused while the Server Dialback negotiation takes + * place. This can easily cause the SO_TIMEOUT to run out. To prevent issues, this implementation allows for reads of the first socket connection + * to time out for a certain number of times, before treating this as a terminal exception. + */ + private int allowableSocketTimeouts = 0; + + private SocketProcessor(int port) throws IOException + { + socket = new Socket(); + final InetSocketAddress socketAddress = new InetSocketAddress(InetAddress.getLoopbackAddress(), port); + if (doLog) System.out.println("Creating new socket to " + socketAddress); + socket.connect(socketAddress, (int) SO_TIMEOUT.toMillis()); + os = socket.getOutputStream(); + is = socket.getInputStream(); + } + + private SocketProcessor(Socket socket) throws IOException + { + if (doLog) System.out.println("New session on socket"); + + this.socket = socket; + os = socket.getOutputStream(); + is = socket.getInputStream(); + } + + public synchronized void send(final String data) throws IOException + { + if (doLog) System.out.println("# send from remote to Openfire" + (socket instanceof SSLSocket ? " (encrypted)" : "")); + if (doLog) System.out.println(data); + if (doLog) System.out.println(); + os.write(data.getBytes()); + os.flush(); + } + + @Override + public void run() + { + if (doLog) System.out.println("Start reading from socket" + (socket instanceof SSLSocket ? " (encrypted)" : "")); + try { + sendStreamHeader(); + + do { + try { + final byte[] buffer = new byte[1024 * 16]; + int count; + while (!processingService.isShutdown() && (count = is.read(buffer)) > 0) { + String read = new String(buffer, 0, count); + if (read.startsWith("") + 2; + read = read.substring(endProlog); + } + if (read.startsWith("", " xmlns:stream=\"http://etherx.jabber.org/streams\">"); + if (doLog) System.out.println("# recv (Hacked inbound stanza to include stream namespace declaration)" + (socket instanceof SSLSocket ? " (encrypted)" : "")); + } else if (read.startsWith("")) { + Element inbound = parse(read); + if (inbound.getName().equals("stream")) { + // This is expected to be the response stream header. No need to act on this, but if it contains a dialback namespace, then this suggests that the peer supports dialback. + peerAdvertisedDialbackNamespace = inbound.declaredNamespaces().stream().anyMatch(namespace -> "jabber:server:dialback".equals(namespace.getURI())); + switch (inbound.elements().size()) { + case 0: + // Done processing the input. Iterate, to try to read more. + continue; + case 1: + // There are child elements to process! + inbound = inbound.elements().get(0); + break; + default: + // More than one child element. This test implementation can't currently handle that. + throw new IllegalStateException("Unable to process more than one child element."); + } + } + switch (inbound.getName()) { + case "features": + negotiateFeatures(inbound); + break; + case "result": + processDialbackResult(inbound); + break; + case "proceed": + processStartTLSProceed(inbound); + return; // stop reading more from this inputstream. + case "success": // intended fall-through + case "failure": + if (inbound.getNamespaceURI().equals("urn:ietf:params:xml:ns:xmpp-sasl")) { + if (processSaslResponse(inbound)) { + if (doLog) System.out.println("Successfully authenticated using SASL! We're done setting up a connection."); + return; + } else if (peerSupportsDialback && !disableDialback) { + if (doLog) System.out.println("Unable to authenticate using SASL! Dialback seems to be available. Trying that..."); + startDialbackAuth(); + break; + } else { + throw new InterruptedIOException("Unable to authenticate"); + } + } else if (inbound.getNamespaceURI().equals("urn:ietf:params:xml:ns:xmpp-tls")) { + throw new InterruptedIOException("Received StartTLS failure from Openfire. Aborting connection"); + } + // intended fall-through + default: + if (doLog) System.out.println("Received stanza '" + inbound.getName() + "' that I don't know how to respond to." + (socket instanceof SSLSocket ? " (encrypted)" : "")); + } + } else { + // received an end of stream: if the peer closes the connection, then we're done trying. + break; + } + } + } catch (SocketTimeoutException e) { + allowableSocketTimeouts--; + if (allowableSocketTimeouts <= 0) { + throw e; + } + } + } while (!processingService.isShutdown() && allowableSocketTimeouts > 0); + if (doLog) System.out.println("Ending read loop."); + } catch (Throwable t) { + // Log exception only when not cleanly closed. + if (doLog && !processingService.isShutdown()) { + t.printStackTrace(); + } + } finally { + if (doLog) System.out.println("Stopped reading from socket"); + done(); + } + } + + private synchronized void sendStreamHeader() throws IOException + { + final Document outbound = DocumentHelper.createDocument(); + final Namespace namespace = new Namespace("stream", "http://etherx.jabber.org/streams"); + final Element root = outbound.addElement(QName.get("stream", namespace)); + root.add(Namespace.get("jabber:server")); + + if (!disableDialback) { + root.add(new Namespace("db", "jabber:server:dialback")); + } + root.addAttribute("from", XMPP_DOMAIN); + root.addAttribute("to", connectTo); + root.addAttribute("version", "1.0"); + + send(root.asXML().substring(0, root.asXML().indexOf(""))); + } + + private void negotiateFeatures(final Element features) throws IOException + { + if (!attemptedEncryptionNegotiation) { + attemptedEncryptionNegotiation = true; + if (negotiateEncryption(features)) { + return; + } + } + negotiateAuthentication(features); + } + + /** + * Returns 'true' if negotiation was started, false if no negotiation was started. + */ + private boolean negotiateEncryption(final Element features) throws IOException + { + if (doLog) System.out.println("Negotiating encryption..."); + final Element startTLSel = features.element(QName.get("starttls", "urn:ietf:params:xml:ns:xmpp-tls")); + final boolean peerSupportsStartTLS = startTLSel != null; + final boolean peerRequiresStartTLS = peerSupportsStartTLS && startTLSel.element("required") != null; + if (doLog) System.out.println("Openfire " + (peerRequiresStartTLS ? "requires" : (peerSupportsStartTLS ? "supports" : "does not support" )) + " StartTLS. Our own policy: " + encryptionPolicy + "."); + + switch (encryptionPolicy) { + case disabled: + if (peerRequiresStartTLS) { + final Document outbound = DocumentHelper.createDocument(); + final Namespace namespace = new Namespace("stream", "http://etherx.jabber.org/streams"); + final Element root = outbound.addElement(QName.get("stream", namespace)); + root.add(Namespace.get("jabber:server")); + final Element error = root.addElement(QName.get("error", "stream", "http://etherx.jabber.org/streams")); + error.addElement(QName.get("undefined-condition", "urn:ietf:params:xml:ns:xmpp-streams")); + + send(root.asXML().substring(root.asXML().indexOf(">")+1)); + throw new InterruptedIOException("Openfire requires TLS, we disabled it."); + } + return false; + case optional: + if (peerSupportsStartTLS) { + initiateTLS(); + return true; + } + return false; + case required: + if (!peerSupportsStartTLS) { + final Document outbound = DocumentHelper.createDocument(); + final Namespace namespace = new Namespace("stream", "http://etherx.jabber.org/streams"); + final Element root = outbound.addElement(QName.get("stream", namespace)); + root.add(Namespace.get("jabber:server")); + final Element error = root.addElement(QName.get("error", "stream", "http://etherx.jabber.org/streams")); + error.addElement(QName.get("undefined-condition", "urn:ietf:params:xml:ns:xmpp-streams")); + + send(root.asXML().substring(root.asXML().indexOf(">")+1)); + throw new InterruptedIOException("Openfire disabled TLS, we require it."); + } + else + { + initiateTLS(); + return true; + } + default: + throw new IllegalStateException("This implementation does not supported encryption policy: " + encryptionPolicy); + } + } + + private void initiateTLS() throws IOException { + if (doLog) System.out.println("Initiating TLS..."); + final Document outbound = DocumentHelper.createDocument(); + final Element startTls = outbound.addElement(QName.get("starttls", "urn:ietf:params:xml:ns:xmpp-tls")); + send(startTls.asXML()); + } + + private void processStartTLSProceed(Element proceed) throws IOException, NoSuchAlgorithmException, KeyManagementException + { + if (doLog) System.out.println("Received StartTLS proceed."); + if (doLog) System.out.println("Replace the socket with one that will do TLS on the next inbound and outbound data"); + + final SSLContext sc = SSLContext.getInstance("TLSv1.2"); + + TrustManager[] tm = createTrustManagerThatTrustsAll(); + SecureRandom random = new SecureRandom(); + + + KeyManager[] km = createKeyManager(new KeyPair(null, null), new X509Certificate[0]); + if (generatedPKIX != null) { + KeyPair keyPair = generatedPKIX.getKeyPair(); + X509Certificate[] certificateChain = generatedPKIX.getCertificateChain(); + km = createKeyManager(keyPair, certificateChain); + } + + sc.init(km, tm , random); + SSLContext.setDefault(sc); + + final SSLSocket sslSocket = (SSLSocket) ((SSLSocketFactory) SSLSocketFactory.getDefault()).createSocket(socket, null, socket.getPort(), true); + sslSocket.setSoTimeout((int) SO_TIMEOUT.toMillis()); + sslSocket.addHandshakeCompletedListener(event -> { if (doLog) System.out.println("SSL handshake completed: " + event); }); + sslSocket.startHandshake(); + + // Just indicate that we would like to authenticate the client but if client + // certificates are self-signed or have no certificate chain then we are still + // good + //sslSocket.setWantClientAuth(true); // DO WE NEED TO BRING THIS INTO OUR LocalOutgoingServerSessionTest MATRIX? + phaser.register(); + + final SocketProcessor sslSocketProcessor = new SocketProcessor(sslSocket); + processingService.submit(sslSocketProcessor); + } + + private void negotiateAuthentication(final Element features) throws IOException { + if (doLog) System.out.println("Negotiating authentication..."); + final Element mechanismsEl = features.element(QName.get("mechanisms", "urn:ietf:params:xml:ns:xmpp-sasl")); + final boolean peerSupportsSASLExternal = mechanismsEl != null && mechanismsEl.elements().stream().anyMatch(element -> "mechanism".equals(element.getName()) && "EXTERNAL".equals(element.getTextTrim())); + peerSupportsDialback = peerAdvertisedDialbackNamespace || features.element(QName.get("dialback", "urn:xmpp:features:dialback")) != null; + if (doLog) System.out.println("Openfire " + (peerSupportsSASLExternal ? "offers" : "does not offer") + " SASL EXTERNAL, " + (peerSupportsDialback ? "supports" : "does not support") + " Server Dialback. Our own policy: SASL EXTERNAL " + (encryptionPolicy != Connection.TLSPolicy.disabled ? "available" : "not available") + ", Dialback: " + (!disableDialback ? "supported" : "not supported") + "."); + + if (peerSupportsSASLExternal && encryptionPolicy != Connection.TLSPolicy.disabled && !alreadyTriedSaslExternal) { + authenticateUsingSaslExternal(); + } else if (peerSupportsDialback && !disableDialback) { + startDialbackAuth(); + } else { + if (doLog) System.out.println("Unable to do authentication."); + throw new InterruptedIOException("Unable to do authentication."); + } + } + + private void authenticateUsingSaslExternal() throws IOException { + if (doLog) System.out.println("Authenticating using SASL EXTERNAL"); + alreadyTriedSaslExternal = true; + final Document outbound = DocumentHelper.createDocument(); + final Element root = outbound.addElement(QName.get("auth", "urn:ietf:params:xml:ns:xmpp-sasl")); + root.addAttribute("mechanism", "EXTERNAL"); + root.setText(Base64.encodeBytes(XMPP_DOMAIN.getBytes())); + send(root.asXML()); + } + + private void startDialbackAuth() throws IOException { + if (doLog) System.out.println("Authenticating using Server Dialback"); + allowableSocketTimeouts = 10; + final String key = "UNITTESTDIALBACKKEY"; + + final Document outbound = DocumentHelper.createDocument(); + final Element root = outbound.addElement(QName.get("result", "db", "urn:xmpp:features:dialback")); + root.addAttribute("from", XMPP_DOMAIN); + root.addAttribute("to", connectTo); + root.setText(key); + + send(root.asXML().replace(" xmlns:db=\"urn:xmpp:features:dialback\"","")); + } + + private void processDialbackResult(final Element result) throws IOException { + final String type = result.attributeValue("type"); + if (doLog) System.out.println("Openfire reports Server Dialback result of type " + type); + if (!"valid".equals(type)) { + throw new InterruptedIOException("Server Dialback failed"); + } + + if (doLog) System.out.println("Successfully authenticated using Server Dialback! We're done setting up a connection."); + done(); + } + + private boolean processSaslResponse(final Element result) throws IOException { + final String name = result.getName(); + if (doLog) System.out.println("Openfire reports SASL result of type " + name); + return "success".equals(name); + } + } +} diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/session/RemoteReceivingServerDummy.java b/xmppserver/src/test/java/org/jivesoftware/openfire/session/RemoteReceivingServerDummy.java new file mode 100644 index 0000000000..75f357e5d2 --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/session/RemoteReceivingServerDummy.java @@ -0,0 +1,472 @@ +/* + * Copyright (C) 2023 Ignite Realtime Foundation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.openfire.session; + +import org.dom4j.*; +import org.jivesoftware.openfire.Connection; +import org.jivesoftware.openfire.keystore.KeystoreTestUtils; +import org.jivesoftware.util.StringUtils; + +import javax.net.ssl.*; +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.security.KeyPair; +import java.security.Principal; +import java.security.PrivateKey; +import java.security.cert.*; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Provides a network entity that mimics the behavior of a remote XMPP server, when accepting a socket connection. + * + * @author Guus der Kinderen, guus@goodbytes.nl + */ +public class RemoteReceivingServerDummy extends AbstractRemoteServerDummy implements AutoCloseable +{ + /** + * When switched to 'true', most XMPP interaction will be printed to standard-out. + */ + public static final boolean doLog = false; + + private ServerSocket server; + + private Thread acceptThread; + private Acceptor acceptor = new Acceptor(); + + private ExecutorService processingService; + + /** + * Start accepting socket connections. + * + * The port on which socket connections are accepted is automatically allocated. Use {@link #getPort()} to obtain it. + */ + public void open() throws Exception + { + if (server != null) { + throw new IllegalStateException("Server already open."); + } + + server = new ServerSocket(0); + + processingService = Executors.newCachedThreadPool(); + + acceptThread = new Thread(acceptor); + acceptThread.start(); + } + + /** + * Stops accepting socket connections and halts processing of data. + */ + @Override + public void close() throws Exception + { + stopAcceptThread(); + + stopProcessingService(); + + if (server != null) { + server.close(); + server = null; + } + } + + /** + * Get the port on which this instance is accepting sockets. + * + * @return a port number. + */ + public int getPort() throws IOException + { + if (server == null) { + throw new IllegalStateException("Server not yet running. Did you call 'open()'?"); + } + return server.getLocalPort(); + } + + public void stopAcceptThread() throws InterruptedException + { + acceptor.stop(); + acceptThread.interrupt(); + final Instant end = Instant.now().plus(SO_TIMEOUT.multipliedBy(20)); + while (Instant.now().isBefore(end) && acceptThread.getState() != Thread.State.TERMINATED) { + Thread.sleep(SO_TIMEOUT.dividedBy(10).toMillis()); + } + final Thread.State finalState = acceptThread.getState(); + if (finalState != Thread.State.TERMINATED) { + if (doLog) System.err.println("Accept thread not terminating after it was stopped. Current state: " + finalState); + if (doLog) Arrays.stream(acceptThread.getStackTrace()).forEach(System.err::println); + acceptThread.stop(); + } + acceptThread = null; + } + + public synchronized void stopProcessingService() throws InterruptedException + { + processingService.shutdown(); + final Instant end = Instant.now().plus(SO_TIMEOUT.multipliedBy(20)); + while (Instant.now().isBefore(end) && !processingService.isTerminated()) { + Thread.sleep(SO_TIMEOUT.dividedBy(10).toMillis()); + } + if (!processingService.isTerminated()) { + processingService.shutdownNow(); + } + } + + private class Acceptor implements Runnable + { + boolean shouldStop = false; + + void stop() { + shouldStop = true; + } + + @Override + public void run() + { + if (doLog) System.out.println("Start accepting socket connections."); + while (!shouldStop) { + try { + server.setSoTimeout((int)SO_TIMEOUT.multipliedBy(10).toMillis()); + final Socket socket = server.accept(); + if (doLog) System.out.println("Accepted new socket connection."); + + processingService.submit(new SocketProcessor(socket)); + } catch (Throwable t) { + // Log exception only when not cleanly closed. + if (acceptThread != null && !acceptThread.isInterrupted()) { + if (!(t instanceof SocketTimeoutException)) { + t.printStackTrace(); + } + } else { + break; + } + } + } + if (doLog) System.out.println("Stopped socket accepting connections."); + } + } + + private class SocketProcessor implements Runnable + { + private Socket socket; + private OutputStream os; + private InputStream is; + private boolean isAuthenticated = false; + + /** + * To speed up the test execution, SO_TIMEOUT (the socket read timeout) has been set to a low value. When negotiating Server Dialback, a second + * socket connection is used. XMPP session establishment on the first socket connection is paused while the Server Dialback negotiation takes + * place. This can easily cause the SO_TIMEOUT to run out. To prevent issues, this implementation allows for reads of the first socket connection + * to time out for a certain number of times, before treating this as a terminal exception. + */ + private int allowableSocketTimeouts = 0; + + private SocketProcessor(Socket socket) throws IOException + { + if (doLog) System.out.println("New session on socket."); + + if (socket instanceof SSLSocket) { + allowableSocketTimeouts = 10; // A new TLS-connection has been observed to require some extra time (when Dialback-over-TLS is happening). + } + this.socket = socket; + os = socket.getOutputStream(); + is = socket.getInputStream(); + } + + public synchronized void send(final String data) throws IOException + { + if (doLog) System.out.println("# send from remote to Openfire" + (socket instanceof SSLSocket ? " (encrypted)" : "")); + if (doLog) System.out.println(data); + if (doLog) System.out.println(); + os.write(data.getBytes()); + os.flush(); + } + + @Override + public void run() + { + if (doLog) System.out.println("Start reading from socket."); + try { + do { + try { + final byte[] buffer = new byte[1024 * 16]; + int count; + while (!processingService.isShutdown() && (count = is.read(buffer)) > 0) { + String read = new String(buffer, 0, count); + + // Ugly hack to get Dialback to work. + if (read.startsWith("")) { + if (doLog) System.out.println("Peer sends a stream error. Can't use this connection anymore."); + return; + } + if (!read.equals("")) { + final Element inbound = parse(read); + switch (inbound.getName()) { + case "stream": + sendStreamHeader(inbound); + sendStreamFeatures(); + break; + case "starttls": + sendStartTlsProceed(inbound); + return; // Stop reading from this socket immediately, as it is replaced by a secure socket. + case "auth": + sendAuthResponse(inbound); + break; + case "result": + processDialback(inbound); + break; + default: + if (doLog) System.out.println("Received stanza '" + inbound.getName() + "' that I don't know how to respond to."); + } + } + } + } catch (SocketTimeoutException e) { + allowableSocketTimeouts--; + if (allowableSocketTimeouts <= 0) { + throw e; + } + } + } while (!processingService.isShutdown() && allowableSocketTimeouts > 0); + if (doLog) System.out.println("Ending read loop." + (socket instanceof SSLSocket ? " (encrypted)" : "")); + } catch (Throwable t) { + // Log exception only when not cleanly closed. + if (doLog && !processingService.isShutdown()) { + t.printStackTrace(); + } + } finally { + if (doLog) System.out.println("Stopped reading from socket"); + } + } + + private synchronized void sendStreamHeader(Element inbound) throws IOException + { + final Document outbound = DocumentHelper.createDocument(); + final Namespace namespace = new Namespace("stream", "http://etherx.jabber.org/streams"); + final Element root = outbound.addElement(QName.get("stream", namespace)); + root.add(Namespace.get("jabber:server")); + + if (!disableDialback) { + root.add(new Namespace("db", "jabber:server:dialback")); + } + root.addAttribute("from", XMPP_DOMAIN); + root.addAttribute("to", inbound.attributeValue("from", null)); + root.addAttribute("version", "1.0"); + root.addAttribute("id", StringUtils.randomString(5)); + + send(root.asXML().substring(0, root.asXML().indexOf(""))); + } + + private synchronized void sendStreamFeatures() throws IOException + { + final Document root = DocumentHelper.createDocument(); + final Element features = root.addElement("features"); + if (!(socket instanceof SSLSocket)) { + if (encryptionPolicy != Connection.TLSPolicy.disabled) { + final Element startTLS = features.addElement(QName.get("starttls", "urn:ietf:params:xml:ns:xmpp-tls")); + if (encryptionPolicy == Connection.TLSPolicy.required) { + startTLS.addElement("required"); + } + } + if (!isAuthenticated) { + if (!disableDialback && encryptionPolicy != Connection.TLSPolicy.required) { // do not offer Dialback if we expect TLS first. + features.addElement(QName.get("dialback", "urn:xmpp:features:dialback")); + allowableSocketTimeouts = 10; // It's possible that the peer will start dialback. If that's happening, we need to be more forgiving in regard to socket timeouts. + } + } + } else if (!isAuthenticated) { + if (!disableDialback) { + features.addElement(QName.get("dialback", "urn:xmpp:features:dialback")); + allowableSocketTimeouts = 10; // It's possible that the peer will start dialback. If that's happening, we need to be more forgiving in regard to socket timeouts. + } + final Element mechanisms = features.addElement(QName.get("mechanisms", "urn:ietf:params:xml:ns:xmpp-sasl")); + if (doLog) System.out.println(((SSLSocket) socket).getSession().getProtocol()); + if (doLog) System.out.println(((SSLSocket) socket).getSession().getCipherSuite()); + + try { + // Throws an exception if the peer (local server) doesn't send a certificate + if (doLog) System.out.println(((SSLSocket) socket).getSession().getPeerPrincipal()); + Certificate[] certificates = ((SSLSocket) socket).getSession().getPeerCertificates(); + if (certificates != null && encryptionPolicy != Connection.TLSPolicy.disabled) { + try { + ((X509Certificate) certificates[0]).checkValidity(); // first peer certificate will belong to the local server + mechanisms.addElement("mechanism").addText("EXTERNAL"); + } catch (CertificateExpiredException | CertificateNotYetValidException e) { + if (doLog) System.out.println("local certificate is invalid"); + } + } + } catch (SSLPeerUnverifiedException e) { + if (doLog) System.out.println("local certificate is missing/unverified"); + } + } + + send(root.getRootElement().asXML()); + } + + private synchronized void sendStartTlsProceed(Element inbound) throws Exception + { + if (encryptionPolicy == Connection.TLSPolicy.disabled) { + final Document outbound = DocumentHelper.createDocument(); + final Namespace namespace = new Namespace("stream", "http://etherx.jabber.org/streams"); + final Element root = outbound.addElement(QName.get("stream", namespace)); + root.add(Namespace.get("jabber:server")); + root.addElement(QName.get("failure", "urn:ietf:params:xml:ns:xmpp-tls")); + + send(root.asXML().substring(root.asXML().indexOf(">")+1)); + throw new InterruptedIOException("TLS Start received while feature is disabled. Kill the connection"); + } + + final Document outbound = DocumentHelper.createDocument(); + outbound.addElement(QName.get("proceed", "urn:ietf:params:xml:ns:xmpp-tls")); + + send(outbound.getRootElement().asXML()); + + if (doLog) System.out.println("Replace the socket with one that will do TLS on the next inbound and outbound data"); + + final SSLContext sc = SSLContext.getInstance("TLSv1.2"); + + sc.init(createKeyManager(generatedPKIX == null ? null : generatedPKIX.getKeyPair(), generatedPKIX == null ? null : generatedPKIX.getCertificateChain()), createTrustManagerThatTrustsAll(), new java.security.SecureRandom()); + SSLContext.setDefault(sc); + + final SSLSocket sslSocket = (SSLSocket) ((SSLSocketFactory) SSLSocketFactory.getDefault()).createSocket(socket, null, true); + sslSocket.setSoTimeout((int) SO_TIMEOUT.toMillis()); + + // Just indicate that we would like to authenticate the client but if client + // certificates are self-signed or have no certificate chain then we are still + // good + sslSocket.setWantClientAuth(true); // DO WE NEED TO BRING THIS INTO OUR LocalOutgoingServerSessionTest MATRIX? + + processingService.submit(new SocketProcessor(sslSocket)); + } + + /** + * Responds to a SASL auth request with a SASL result indicating that authentication succeeded. + * + * Very basic verification is performed by this implementation: if the peer provides a certificate that's not expired, authentication is accepted. + * + * If TLS is disabled, this sends an authentication failure response. + * + * @param inbound The SASL request to authenticate. + */ + private synchronized void sendAuthResponse(Element inbound) throws IOException + { + if (encryptionPolicy == Connection.TLSPolicy.disabled) { + isAuthenticated = false; + + final Document outbound = DocumentHelper.createDocument(); + final Element failure = outbound.addElement(QName.get("failure", "urn:ietf:params:xml:ns:xmpp-sasl")); + failure.addElement(QName.get("not-authorized")); + + send(failure.asXML()); + } + + if (!(socket instanceof SSLSocket)) { + final Document outbound = DocumentHelper.createDocument(); + final Element failure = outbound.addElement(QName.get("failure", "urn:ietf:params:xml:ns:xmpp-sasl")); + failure.addElement(QName.get("encryption-required")); + + send(failure.asXML()); + } + + final X509Certificate[] peerCertificates = (X509Certificate[]) ((SSLSocket) socket).getSession().getPeerCertificates(); + if (peerCertificates == null || peerCertificates.length == 0 || Instant.now().isAfter(peerCertificates[0].getNotAfter().toInstant()) || Instant.now().isBefore(peerCertificates[0].getNotBefore().toInstant())) { + final Document outbound = DocumentHelper.createDocument(); + final Element failure = outbound.addElement(QName.get("failure", "urn:ietf:params:xml:ns:xmpp-sasl")); + failure.addElement(QName.get("not-authorized")); + + send(failure.asXML()); + } + + + isAuthenticated = true; + final Document root = DocumentHelper.createDocument(); + root.addElement(QName.get("success", "urn:ietf:params:xml:ns:xmpp-sasl")); + send(root.getRootElement().asXML()); + } + + /** + * Responds to a Dialback request with a Dialback result indicating that authentication succeeded. + * + * Proper Dialback should first verify with an Authoritive Server from the remote domain. This method skips that, + * and authenticates blindly. + * + * @param inbound The Dialback request to authenticate. + */ + private synchronized void processDialback(Element inbound) throws IOException + { + allowableSocketTimeouts = 10; + + if (disableDialback) { + final Document outbound = DocumentHelper.createDocument(); + final Namespace namespace = new Namespace("stream", "http://etherx.jabber.org/streams"); + final Element root = outbound.addElement(QName.get("stream", namespace)); + root.add(Namespace.get("jabber:server")); + final Element error = root.addElement(QName.get("error", "stream", "http://etherx.jabber.org/streams")); + error.addElement(QName.get("unsupported-stanza-type", "urn:ietf:params:xml:ns:xmpp-streams")); + + send(root.asXML().substring(root.asXML().indexOf(">")+1)); + throw new InterruptedIOException("Dialback received while feature is disabled. Kill the connection"); + } + + if (encryptionPolicy == Connection.TLSPolicy.required && !(socket instanceof SSLSocket)) { + final Document outbound = DocumentHelper.createDocument(); + final Element result = outbound.addElement(QName.get("result", "db", "urn:xmpp:features:dialback")); + result.addAttribute("from", XMPP_DOMAIN); + result.addAttribute("to", inbound.attributeValue("from", null)); + result.addAttribute("type", "error"); + final Element error = result.addElement("error"); + error.addAttribute("type", "cancel"); + error.addElement("policy-violation", "urn:ietf:params:xml:ns:xmpp-stanzas"); + + send(outbound.getRootElement().asXML()); + return; // spec says to not kill the connection. + } + + if (inbound.getTextTrim().isEmpty()) { + throw new IllegalStateException("Not supporting processing anything but an initial dialback key submission."); + } + + // Skip the check with an Authoritative Server (which is what Dialback _should_ do). Simply report a faked validation result. + final Document outbound = DocumentHelper.createDocument(); + final Element result = outbound.addElement(QName.get("result", "db", "urn:xmpp:features:dialback")); + result.addAttribute("from", XMPP_DOMAIN); + result.addAttribute("to", inbound.attributeValue("from", null)); + result.addAttribute("type", "valid"); + + send(outbound.getRootElement().asXML()); + } + } +} diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/session/ServerSettings.java b/xmppserver/src/test/java/org/jivesoftware/openfire/session/ServerSettings.java new file mode 100644 index 0000000000..4e08e230c0 --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/session/ServerSettings.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2023 Ignite Realtime Foundation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.openfire.session; + +import org.jivesoftware.openfire.Connection; + +/** + * Representation of a particular state of server configuration. + */ +public class ServerSettings +{ + public enum CertificateState + { + /** + * Server does not offer a TLS certificate. + */ + MISSING, + + /** + * Server offers a TLS certificate that is somehow not valid (it expired, is self-signed, uses a root CA that's not recognized by the peer, uses an incorrect identity, etc). + */ + INVALID, + + /** + * Server offers a TLS certificate that is valid. + */ + VALID + } + +// public enum TlsMutualAuthenticationPolicy { +// /** Local Server will not attempt to identify peer, using its TLS certificate. */ +// DISABLED, +// +// /** Local Server will attempt to identify peer, but only if it provides its TLS certificate. */ +// WANTED, +// +// /** Local Server will fail to establish a connection if it cannot verify peer's TLS certificate. */ +// NEEDED +// } + + /** + * Defines if this entity requires/disables or can use TLS for encryption (this does not mandate TLS-based authentication). + */ + public final Connection.TLSPolicy encryptionPolicy; + + /** + * Describes the certificate that's offered by this entity. + */ + public final CertificateState certificateState; + + /** + * When Dialback is allowed, unauthenticated TLS encryption is better than no encryption. This, however, breaks with + * a strict interpretation of RFC 6120 section 13.7.2 (while it appears allowable in RFC 7590 Section 3.4). Openfire + * con be configured either way, by setting the 'strict certificate validation' configuration. This field will take + * into account that setting. + */ + public final boolean strictCertificateValidation; + + /** + * Defines if this entity will support the Dialback authentication mechanism. + */ + public final boolean dialbackSupported; + +// /** +// * Defines if this entity will attempt/require/ignore to validate the peer's certificate +// */ +// public final TlsMutualAuthenticationPolicy tlsMutualAuthenticationPolicy; + + public ServerSettings(final Connection.TLSPolicy encryptionPolicy, final CertificateState certificateState, final boolean strictCertificateValidation, final boolean dialbackSupported) + { + this.encryptionPolicy = encryptionPolicy; + this.certificateState = certificateState; + this.strictCertificateValidation = strictCertificateValidation; + this.dialbackSupported = dialbackSupported; + } + + @Override + public String toString() + { + return toString(5); + } + + public String toString(int length) + { + if (length > 0) { + return "[encryption=" + encryptionPolicy.toString().substring(0, length) + ", certificate=" + certificateState.toString().substring(0, length) + ", strictCertValidation=" + strictCertificateValidation + ", dialback=" + (dialbackSupported ? "SUPPORTED" : "DISABLED").substring(0, length) + "]"; + } else { + return "[encryption=" + encryptionPolicy.toString() + ", certificate=" + certificateState.toString() + ", strictCertValidation=" + strictCertificateValidation + ", dialback=" + (dialbackSupported ? "SUPPORTED" : "DISABLED") + "]"; + } + } +} diff --git a/xmppserver/src/test/java/org/jivesoftware/util/CertificateManagerTest.java b/xmppserver/src/test/java/org/jivesoftware/util/CertificateManagerTest.java index 1b35d4d829..300badd1fd 100644 --- a/xmppserver/src/test/java/org/jivesoftware/util/CertificateManagerTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/util/CertificateManagerTest.java @@ -62,6 +62,10 @@ public class CertificateManagerTest public static final ASN1ObjectIdentifier XMPP_ADDR_OID = new ASN1ObjectIdentifier( "1.3.6.1.5.5.7.8.5" ); public static final ASN1ObjectIdentifier DNS_SRV_OID = new ASN1ObjectIdentifier( "1.3.6.1.5.5.7.8.7" ); + public static final int KEY_SIZE = 512; + public static final String KEY_ALGORITHM = "RSA"; + public static final String SIGNATURE_ALGORITHM = "SHA1withRSA"; + private static KeyPairGenerator keyPairGenerator; private static KeyPair subjectKeyPair; private static KeyPair issuerKeyPair; @@ -70,12 +74,12 @@ public class CertificateManagerTest @BeforeAll public static void initialize() throws Exception { - keyPairGenerator = KeyPairGenerator.getInstance( "RSA" ); - keyPairGenerator.initialize( 512 ); + keyPairGenerator = KeyPairGenerator.getInstance( KEY_ALGORITHM ); + keyPairGenerator.initialize( KEY_SIZE ); subjectKeyPair = keyPairGenerator.generateKeyPair(); issuerKeyPair = keyPairGenerator.generateKeyPair(); - contentSigner = new JcaContentSignerBuilder( "SHA1withRSA" ).build( issuerKeyPair.getPrivate() ); + contentSigner = new JcaContentSignerBuilder( SIGNATURE_ALGORITHM ).build( issuerKeyPair.getPrivate() ); } /** @@ -347,11 +351,10 @@ public void testGenerateCertificateDateValidity() throws Exception final String issuerCommonName = "issuer common name"; final String subjectCommonName = "subject common name"; final String domain = "domain.example.org"; - final String signAlgoritm = "SHA256WITHRSAENCRYPTION"; final Set sanDnsNames = Stream.of( "alternative-a.example.org", "alternative-b.example.org" ).collect( Collectors.toSet() ); // Execute system under test. - final X509Certificate result = CertificateManager.createX509V3Certificate( keyPair, days, issuerCommonName, subjectCommonName, domain, signAlgoritm, sanDnsNames ); + final X509Certificate result = CertificateManager.createX509V3Certificate( keyPair, days, issuerCommonName, subjectCommonName, domain, SIGNATURE_ALGORITHM, sanDnsNames ); // Verify results. assertNotNull( result ); @@ -374,11 +377,10 @@ public void testGenerateCertificateIssuer() throws Exception final String issuerCommonName = "issuer common name"; final String subjectCommonName = "subject common name"; final String domain = "domain.example.org"; - final String signAlgoritm = "SHA256WITHRSAENCRYPTION"; final Set sanDnsNames = Stream.of( "alternative-a.example.org", "alternative-b.example.org" ).collect( Collectors.toSet() ); // Execute system under test. - final X509Certificate result = CertificateManager.createX509V3Certificate( keyPair, days, issuerCommonName, subjectCommonName, domain, signAlgoritm, sanDnsNames ); + final X509Certificate result = CertificateManager.createX509V3Certificate( keyPair, days, issuerCommonName, subjectCommonName, domain, SIGNATURE_ALGORITHM, sanDnsNames ); // Verify results. assertNotNull( result ); @@ -400,11 +402,10 @@ public void testGenerateCertificateSubject() throws Exception final String issuerCommonName = "issuer common name"; final String subjectCommonName = "subject common name"; final String domain = "domain.example.org"; - final String signAlgoritm = "SHA256WITHRSAENCRYPTION"; final Set sanDnsNames = Stream.of( "alternative-a.example.org", "alternative-b.example.org" ).collect( Collectors.toSet() ); // Execute system under test. - final X509Certificate result = CertificateManager.createX509V3Certificate( keyPair, days, issuerCommonName, subjectCommonName, domain, signAlgoritm, sanDnsNames ); + final X509Certificate result = CertificateManager.createX509V3Certificate( keyPair, days, issuerCommonName, subjectCommonName, domain, SIGNATURE_ALGORITHM, sanDnsNames ); // Verify results. assertNotNull( result ); @@ -426,11 +427,10 @@ public void testGenerateCertificateSubjectAlternativeNames() throws Exception final String issuerCommonName = "issuer common name"; final String subjectCommonName = "subject common name"; final String domain = "domain.example.org"; - final String signAlgoritm = "SHA256WITHRSAENCRYPTION"; final Set sanDnsNames = Stream.of( "alternative-a.example.org", "alternative-b.example.org" ).collect( Collectors.toSet() ); // Execute system under test. - final X509Certificate result = CertificateManager.createX509V3Certificate( keyPair, days, issuerCommonName, subjectCommonName, domain, signAlgoritm, sanDnsNames ); + final X509Certificate result = CertificateManager.createX509V3Certificate( keyPair, days, issuerCommonName, subjectCommonName, domain, SIGNATURE_ALGORITHM, sanDnsNames ); // Verify results. assertNotNull( result );