diff --git a/documentation/protocol-support.html b/documentation/protocol-support.html
index b69f868e5a..1b60263f56 100644
--- a/documentation/protocol-support.html
+++ b/documentation/protocol-support.html
@@ -399,6 +399,7 @@ List of other XEPs Supported
| XEP-0359: Unique and Stable Stanza IDs |
| XEP-0398: User Avatar to vCard-Based Avatars Conversion |
| XEP-0433: Extended Channel Search |
+ | XEP-0440: SASL Channel-Binding Type Capability |
| XEP-0478: Stream Limits Advertisement |
| XEP-0483: HTTP Online Meetings [19] |
| XEP-0485: PubSub Server Information [18] |
diff --git a/i18n/src/main/resources/openfire_i18n.properties b/i18n/src/main/resources/openfire_i18n.properties
index 0f1d18a94a..08074fe39f 100644
--- a/i18n/src/main/resources/openfire_i18n.properties
+++ b/i18n/src/main/resources/openfire_i18n.properties
@@ -1155,6 +1155,7 @@ reg.settings.description.SKEY=An S/KEY mechanism.
reg.settings.description.CRAM-MD5=Simple challenge-response scheme based on HMAC-MD5.
reg.settings.description.DIGEST-MD5=Challenge-response scheme based upon MD5. DIGEST-MD5 offered a data security layer.
reg.settings.description.SCRAM-SHA-1=Salted challenge-response scheme based on SHA-1.
+reg.settings.description.SCRAM-SHA-1-PLUS=Salted challenge-response scheme based on SHA-1 with channel binding.
reg.settings.description.SCRAM=Challenge-response scheme based mechanism with channel binding support.
reg.settings.description.NTLM=NT LAN Manager authentication mechanism.
reg.settings.description.GSSAPI=Kerberos V5 authentication via the GSSAPI. GSSAPI offers a data-security layer.
@@ -1855,6 +1856,7 @@ session.details.anon-status=Using Anonymous Authentication
session.details.anon-true=Yes
session.details.anon-false=No
session.details.sasl-mechanism=SASL Mechanism
+session.details.channel-binding-type=Channel Binding Type
session.details.flomr-status=Flexible Offline Message Retrieval
session.details.flomr-enabled=Enabled
session.details.flomr-disabled=Disabled
diff --git a/i18n/src/main/resources/openfire_i18n_nl.properties b/i18n/src/main/resources/openfire_i18n_nl.properties
index 6691842312..16addd06f7 100644
--- a/i18n/src/main/resources/openfire_i18n_nl.properties
+++ b/i18n/src/main/resources/openfire_i18n_nl.properties
@@ -1071,8 +1071,9 @@ reg.settings.description.OTP=Eenmalig wachtwoord mechanisme.
reg.settings.description.SKEY=Een S/KEY mechanisme.
reg.settings.description.CRAM-MD5=Eenvoudig Challenge-response mechanisme gebaseed op HMAC-MD5.
reg.settings.description.DIGEST-MD5=Challenge-response mechanisme gebaseed op MD5. DIGEST-MD5 biedt data-laag beveiliging.
-reg.settings.description.SCRAM-SHA-1=Salted challenge-response mechanisme gebaseed op SHA-1.
-reg.settings.description.SCRAM=Chellenge-response-gebaseerd mechanisme met channel-binding ondersteuning.
+reg.settings.description.SCRAM-SHA-1=Salted challenge-response mechanisme gebaseerd op SHA-1.
+reg.settings.description.SCRAM-SHA-1-PLUS=Salted challenge-response mechanisme (met channel binding) gebaseerd op SHA-1.
+reg.settings.description.SCRAM=Challenge-response-gebaseerd mechanisme met channel-binding ondersteuning.
reg.settings.description.NTLM=NT LAN Manager authenticatie mechanisme.
reg.settings.description.GSSAPI=Kerberos V5 authenticatie via GSSAPI. GSSAPI biedt een data-laag beveiliging.
reg.settings.description.EAP-AES128=GSS EAP authenticatie.
@@ -1732,6 +1733,7 @@ session.details.anon-status=Gebruik Anonieme Authenticatie
session.details.anon-true=Ja
session.details.anon-false=Nee
session.details.sasl-mechanism=SASL Mechanisme
+session.details.channel-binding-type=Channel Binding Type
session.details.flomr-status=Offline Berichten Flexibel Ophalen
session.details.flomr-enabled=Ingeschakeld
session.details.flomr-disabled=Uitgeschakeld
diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/Connection.java b/xmppserver/src/main/java/org/jivesoftware/openfire/Connection.java
index 20103f04eb..0be277bd32 100644
--- a/xmppserver/src/main/java/org/jivesoftware/openfire/Connection.java
+++ b/xmppserver/src/main/java/org/jivesoftware/openfire/Connection.java
@@ -28,6 +28,7 @@
import java.io.Closeable;
import java.net.UnknownHostException;
import java.security.cert.Certificate;
+import java.util.Collections;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletionStage;
@@ -136,6 +137,49 @@ public interface Connection extends Closeable {
*/
Certificate[] getPeerCertificates();
+ /**
+ * Returns channel binding data for this connection, as defined by the provided type.
+ *
+ * Channel binding data is used to bind higher-level authentication to the underlying transport layer, improving
+ * security against man-in-the-middle attacks.
+ *
+ * The type, identified by a unique prefix that's typically defined in an RFC, determines which channel binding
+ * mechanism is used, such as:
+ *
+ * tls-exporter: TLS exporter-based channel binding.
+ * tls-server-end-point: Uses the hash of the server certificate (RFC 5929).
+ *
+ *
+ * Note that channel binding type prefixes are case-sensitive.
+ *
+ * If the connection is not encrypted, or the requested channel binding type is not available, returns {@link Optional#empty()}.
+ *
+ * @param cbPrefix the RFC-defined unique prefix for the channel binding type (must not be null)
+ * @return An Optional containing the channel binding data, or empty if not available.
+ * @see RFC 5705: Keying Material Exporters for Transport Layer Security (TLS)
+ * @see RFC 5929: Channel Bindings for TLS
+ * @see RFC 9266: Channel Bindings for TLS 1.3
+ */
+ default Optional getChannelBindingData(@Nonnull final String cbPrefix) {
+ return Optional.empty();
+ }
+
+ /**
+ * Returns the unique prefixes of the channel binding types that are supported by this connection in its current
+ * state. Notably, this may change if the connection is encrypted or if the underlying TLS implementation changes.
+ * When no channel binding types are supported, an empty set is returned.
+ *
+ * Implementation note: This method is used to determine if SASL -PLUS mechanisms (such as SCRAM-SHA-1-PLUS)
+ * should be offered to the client. If channel binding is not supported in the current state (e.g., not encrypted,
+ * or the connection type does not support channel binding), this method must return an empty set.
+ *
+ * @return supported channel binding types.
+ */
+ default Set getSupportedChannelBindingTypes()
+ {
+ return Collections.emptySet();
+ }
+
/**
* Keeps track if the other peer of this session presented a self-signed certificate. When
* using self-signed certificate for server-2-server sessions then SASL EXTERNAL will not be
diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/container/AdminConsolePlugin.java b/xmppserver/src/main/java/org/jivesoftware/openfire/container/AdminConsolePlugin.java
index b14891850f..b222a5751a 100644
--- a/xmppserver/src/main/java/org/jivesoftware/openfire/container/AdminConsolePlugin.java
+++ b/xmppserver/src/main/java/org/jivesoftware/openfire/container/AdminConsolePlugin.java
@@ -500,7 +500,7 @@ public ContextHandlerCollection getContexts() {
}
/**
- * Restart the admin console (and it's HTTP server) without restarting the plugin.
+ * Restart the admin console (and its HTTP server) without restarting the plugin.
*/
public void restart() {
try {
diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/group/AbstractGroupProvider.java b/xmppserver/src/main/java/org/jivesoftware/openfire/group/AbstractGroupProvider.java
index 3271f7a284..188479b17f 100644
--- a/xmppserver/src/main/java/org/jivesoftware/openfire/group/AbstractGroupProvider.java
+++ b/xmppserver/src/main/java/org/jivesoftware/openfire/group/AbstractGroupProvider.java
@@ -148,7 +148,7 @@ public Group createGroup(String name) throws GroupAlreadyExistsException, GroupN
sharedGroupMetaCache.clear();
}
- return null; // aught to be overridden.
+ return null; // ought to be overridden.
}
/**
diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java
index 88edb54fa6..dba8971f6e 100644
--- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java
+++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java
@@ -32,12 +32,21 @@
import org.jivesoftware.openfire.sasl.Failure;
import org.jivesoftware.openfire.sasl.JiveSharedSecretSaslServer;
import org.jivesoftware.openfire.sasl.SaslFailureException;
-import org.jivesoftware.openfire.session.*;
+import org.jivesoftware.openfire.sasl.ScramSha1SaslServer;
+import org.jivesoftware.openfire.session.ClientSession;
+import org.jivesoftware.openfire.session.ConnectionSettings;
+import org.jivesoftware.openfire.session.IncomingServerSession;
+import org.jivesoftware.openfire.session.LocalClientSession;
+import org.jivesoftware.openfire.session.LocalIncomingServerSession;
+import org.jivesoftware.openfire.session.LocalSession;
+import org.jivesoftware.openfire.session.ServerSession;
+import org.jivesoftware.openfire.session.Session;
import org.jivesoftware.openfire.spi.ConnectionType;
import org.jivesoftware.util.CertificateManager;
import org.jivesoftware.util.JiveGlobals;
import org.jivesoftware.util.PropertyEventListener;
import org.jivesoftware.util.SystemProperty;
+import org.jivesoftware.util.channelbinding.ChannelBindingProviderManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -277,6 +286,19 @@ public static Element getSASLMechanismsElement( ClientSession session )
continue; // Do not offer EXTERNAL.
}
}
+ if (mech.endsWith("-PLUS")) {
+ // Prevent offering channel binding if the Connection implementation does not support it.
+ final Connection connection = ( (LocalClientSession) session ).getConnection();
+ assert connection != null; // While the client is performing a SASL negotiation, the connection can't be null.
+ if (connection.getSupportedChannelBindingTypes().isEmpty()) {
+ continue;
+ }
+
+ // Channel binding would be a binding to TLS, thus encryption is required for channel binding.
+ if (!session.isEncrypted()) { // This ought to be redundant, as getSupportedChannelBindingTypes() will return an empty set if not encrypted.
+ continue;
+ }
+ }
final Element mechanism = result.addElement("mechanism");
mechanism.setText(mech);
}
@@ -454,6 +476,9 @@ else if (encoded.equals("="))
authenticationSuccessful( session, saslServer.getAuthorizationID(), challenge );
session.removeSessionData( "SaslServer" );
session.setSessionData("SaslMechanism", saslServer.getMechanismName());
+ if (saslServer.getMechanismName().endsWith("-PLUS")) {
+ session.setSessionData("ChannelBindingType", saslServer.getNegotiatedProperty(ScramSha1SaslServer.PROPNAME_CHANNELBINDINGTYPE));
+ }
return Status.authenticated;
default:
@@ -639,6 +664,12 @@ public static Set getSupportedMechanisms()
continue;
}
+ if (mechanism.endsWith("-PLUS") && ChannelBindingProviderManager.getInstance().getSupportedChannelBindingTypes().isEmpty()) {
+ Log.trace( "Cannot support '{}' as there's no implementation available for channel binding.", mechanism );
+ it.remove();
+ continue;
+ }
+
switch ( mechanism )
{
case "CRAM-MD5": // intended fall-through
@@ -646,15 +677,16 @@ public static Set getSupportedMechanisms()
// Check if the user provider in use supports passwords retrieval. Access to the users passwords will be required by the CallbackHandler.
if ( !AuthFactory.supportsPasswordRetrieval() )
{
- Log.trace( "Cannot support '{}' as the AuthFactory that's in use does not support password retrieval.", mechanism );
+ Log.trace( "Cannot support '{}' as the AuthProvider that's in use does not support password retrieval.", mechanism );
it.remove();
}
break;
- case "SCRAM-SHA-1":
+ case "SCRAM-SHA-1": // intended fall-through
+ case "SCRAM-SHA-1-PLUS":
if ( !AuthFactory.supportsScram() )
{
- Log.trace( "Cannot support '{}' as the AuthFactory that's in use does not support SCRAM.", mechanism );
+ Log.trace( "Cannot support '{}' as the AuthProvider that's in use does not support SCRAM.", mechanism );
it.remove();
}
break;
@@ -728,7 +760,7 @@ public static Set getImplementedMechanisms()
*/
public static List getEnabledMechanisms()
{
- return JiveGlobals.getListProperty("sasl.mechs", Arrays.asList( "ANONYMOUS","PLAIN","DIGEST-MD5","CRAM-MD5","SCRAM-SHA-1","JIVE-SHAREDSECRET","GSSAPI","EXTERNAL" ) );
+ return JiveGlobals.getListProperty("sasl.mechs", Arrays.asList( "ANONYMOUS","PLAIN","DIGEST-MD5","CRAM-MD5","SCRAM-SHA-1","SCRAM-SHA-1-PLUS","JIVE-SHAREDSECRET","GSSAPI","EXTERNAL" ) );
}
/**
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 2ac63210a9..8014673f3a 100644
--- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SocketReadingMode.java
+++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SocketReadingMode.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2005-2008 Jive Software, 2017-2025 Ignite Realtime Foundation. All rights reserved.
+ * Copyright (C) 2005-2008 Jive Software, 2017-2026 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.
@@ -20,6 +20,7 @@
import org.jivesoftware.openfire.Connection;
import org.jivesoftware.openfire.session.Session;
import org.jivesoftware.util.StringUtils;
+import org.jivesoftware.util.channelbinding.ChannelBindingProviderManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xmlpull.v1.XmlPullParserException;
@@ -108,6 +109,7 @@ protected void tlsNegotiated() throws XmlPullParserException, IOException
final Element features = DocumentHelper.createElement(QName.get("features", "stream", "http://etherx.jabber.org/streams"));
final Element mechanisms = SASLAuthentication.getSASLMechanisms(socketReader.session);
if (mechanisms != null) {
+ ChannelBindingProviderManager.getInstance().getSASLChannelBindingTypeCapabilityElement(mechanisms).ifPresent(features::add);
features.add(mechanisms);
}
final List specificFeatures = socketReader.session.getAvailableStreamFeatures();
@@ -253,6 +255,7 @@ protected void compressionSuccessful() throws XmlPullParserException, IOExceptio
// Include available SASL Mechanisms
final Element saslMechanisms = SASLAuthentication.getSASLMechanisms(socketReader.session);
if (saslMechanisms != null) {
+ ChannelBindingProviderManager.getInstance().getSASLChannelBindingTypeCapabilityElement(saslMechanisms).ifPresent(features::add);
features.add(saslMechanisms);
}
}
diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java
index 51d51162ec..e7e73e4743 100644
--- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java
+++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java
@@ -29,6 +29,7 @@
import org.jivesoftware.openfire.spi.BasicStreamIDFactory;
import org.jivesoftware.openfire.streammanagement.StreamManager;
import org.jivesoftware.util.*;
+import org.jivesoftware.util.channelbinding.ChannelBindingProviderManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xmlpull.v1.XmlPullParser;
@@ -494,7 +495,8 @@ protected void tlsNegotiated(XmlPullParser xpp) throws XmlPullParserException, I
// Include available SASL Mechanisms
final Element mechanismsElement=SASLAuthentication.getSASLMechanisms(session);
if (mechanismsElement!=null) {
- features.add(mechanismsElement);
+ ChannelBindingProviderManager.getInstance().getSASLChannelBindingTypeCapabilityElement(mechanismsElement).ifPresent(features::add);
+ features.add(mechanismsElement);
}
// Include specific features such as auth and register for client sessions
@@ -597,6 +599,7 @@ protected void compressionSuccessful() {
if (!session.isAuthenticated()) {
final Element saslMechanisms = SASLAuthentication.getSASLMechanisms(session);
if (saslMechanisms != null) {
+ ChannelBindingProviderManager.getInstance().getSASLChannelBindingTypeCapabilityElement(saslMechanisms).ifPresent(features::add);
features.add(saslMechanisms);
}
}
diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/nio/NettyConnection.java b/xmppserver/src/main/java/org/jivesoftware/openfire/nio/NettyConnection.java
index d17504411a..bfafc49856 100644
--- a/xmppserver/src/main/java/org/jivesoftware/openfire/nio/NettyConnection.java
+++ b/xmppserver/src/main/java/org/jivesoftware/openfire/nio/NettyConnection.java
@@ -34,19 +34,24 @@
import org.jivesoftware.openfire.session.Session;
import org.jivesoftware.openfire.spi.ConnectionConfiguration;
import org.jivesoftware.openfire.spi.EncryptionArtifactFactory;
+import org.jivesoftware.util.channelbinding.ChannelBindingProviderManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xmpp.packet.Packet;
import org.xmpp.packet.StreamError;
+import javax.annotation.Nonnull;
import javax.annotation.Nullable;
+import javax.net.ssl.SSLEngine;
import javax.net.ssl.SSLPeerUnverifiedException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.net.UnknownHostException;
import java.security.cert.Certificate;
+import java.util.Collections;
import java.util.Optional;
+import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import static com.jcraft.jzlib.JZlib.Z_BEST_COMPRESSION;
@@ -83,10 +88,13 @@ public class NettyConnection extends AbstractConnection
private final AtomicReference state = new AtomicReference<>(State.OPEN);
private boolean isEncrypted = false;
+ private ChannelBindingProviderManager channelBindingProviderManager; // TODO allow this to be set for unit testing.
+
public NettyConnection(ChannelHandlerContext channelHandlerContext, @Nullable PacketDeliverer packetDeliverer, ConnectionConfiguration configuration ) {
this.channelHandlerContext = channelHandlerContext;
this.backupDeliverer = packetDeliverer;
this.configuration = configuration;
+ this.channelBindingProviderManager = ChannelBindingProviderManager.getInstance();
}
@Override
@@ -405,6 +413,28 @@ public boolean isCompressed() {
return channelHandlerContext.channel().pipeline().get(JZlibDecoder.class) != null;
}
+ @Override
+ public Optional getChannelBindingData(@Nonnull final String cbPrefix)
+ {
+ final SslHandler sslhandler = (SslHandler) channelHandlerContext.channel().pipeline().get(SSL_HANDLER_NAME);
+ if (sslhandler == null) {
+ return Optional.empty();
+ }
+
+ final SSLEngine engine = sslhandler.engine();
+ return channelBindingProviderManager.getChannelBinding(cbPrefix, engine);
+ }
+
+ @Override
+ public Set getSupportedChannelBindingTypes()
+ {
+ final SslHandler sslhandler = (SslHandler) channelHandlerContext.channel().pipeline().get(SSL_HANDLER_NAME);
+ if (sslhandler == null) {
+ return Collections.emptySet();
+ }
+ return channelBindingProviderManager.getSupportedChannelBindingTypes();
+ }
+
@Override
public String toString() {
final SocketAddress peer = channelHandlerContext.channel().remoteAddress();
diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/sasl/SaslProvider.java b/xmppserver/src/main/java/org/jivesoftware/openfire/sasl/SaslProvider.java
index f2aad87ec5..f1300a7978 100644
--- a/xmppserver/src/main/java/org/jivesoftware/openfire/sasl/SaslProvider.java
+++ b/xmppserver/src/main/java/org/jivesoftware/openfire/sasl/SaslProvider.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2004-2008 Jive Software, 2017-2018 Ignite Realtime Foundation. All rights reserved.
+ * Copyright (C) 2004-2008 Jive Software, 2017-2026 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.
@@ -31,7 +31,7 @@ public class SaslProvider extends Provider {
*/
public SaslProvider()
{
- super("JiveSoftware", 1.1, "JiveSoftware Openfire SASL provider v1.1" );
+ super("JiveSoftware", 1.2, "JiveSoftware Openfire SASL provider v1.2" );
final SaslServerFactoryImpl serverFactory = new SaslServerFactoryImpl();
for ( final String name : serverFactory.getMechanismNames( null ) )
diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/sasl/SaslServerFactoryImpl.java b/xmppserver/src/main/java/org/jivesoftware/openfire/sasl/SaslServerFactoryImpl.java
index e149060700..25ce160bcd 100644
--- a/xmppserver/src/main/java/org/jivesoftware/openfire/sasl/SaslServerFactoryImpl.java
+++ b/xmppserver/src/main/java/org/jivesoftware/openfire/sasl/SaslServerFactoryImpl.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2004-2008 Jive Software, 2017-2018 Ignite Realtime Foundation. All rights reserved.
+ * Copyright (C) 2004-2008 Jive Software, 2017-2026 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.
@@ -54,6 +54,7 @@ public SaslServerFactoryImpl()
allMechanisms.add( new Mechanism( "ANONYMOUS", true, true ) );
allMechanisms.add( new Mechanism( "PLAIN", false, true ) );
allMechanisms.add( new Mechanism( "SCRAM-SHA-1", false, false ) );
+ allMechanisms.add( new Mechanism( "SCRAM-SHA-1-PLUS", false, false ) );
allMechanisms.add( new Mechanism( "JIVE-SHAREDSECRET", true, false ) );
allMechanisms.add( new Mechanism( "EXTERNAL", false, false ) );
}
@@ -78,7 +79,10 @@ public SaslServer createSaslServer(String mechanism, String protocol, String ser
return new SaslServerPlainImpl( protocol, serverName, props, cbh );
case "SCRAM-SHA-1":
- return new ScramSha1SaslServer();
+ return new ScramSha1SaslServer(false, props);
+
+ case "SCRAM-SHA-1-PLUS":
+ return new ScramSha1SaslServer(true, props);
case "ANONYMOUS":
if ( !props.containsKey( LocalSession.class.getCanonicalName() ) )
diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/sasl/ScramSha1SaslServer.java b/xmppserver/src/main/java/org/jivesoftware/openfire/sasl/ScramSha1SaslServer.java
index 4b38c57190..51374718a7 100644
--- a/xmppserver/src/main/java/org/jivesoftware/openfire/sasl/ScramSha1SaslServer.java
+++ b/xmppserver/src/main/java/org/jivesoftware/openfire/sasl/ScramSha1SaslServer.java
@@ -20,6 +20,10 @@
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -36,16 +40,19 @@
import org.jivesoftware.openfire.auth.DefaultAuthProvider;
import org.jivesoftware.openfire.auth.InternalUnauthenticatedException;
import org.jivesoftware.openfire.auth.ScramUtils;
+import org.jivesoftware.openfire.net.SASLAuthentication;
+import org.jivesoftware.openfire.session.LocalSession;
import org.jivesoftware.openfire.user.UserNotFoundException;
import org.jivesoftware.util.StringUtils;
import org.jivesoftware.util.SystemProperty;
+import org.jivesoftware.util.channelbinding.ChannelBindingProviderManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
- * Implements the SCRAM-SHA-1 server-side mechanism.
+ * Implements the SCRAM-SHA-1 (and its channel binding -PLUS variant) server-side mechanism.
*
- * @author Richard Midwinter
+ * @author Richard Midwinter, Guus der Kinderen
*/
public class ScramSha1SaslServer implements SaslServer {
@@ -93,6 +100,8 @@ public synchronized static String getServerSecretForNonExistentUsers()
return SERVER_SECRET_NONEXISTENT_USERS.getValue();
}
+ public static final String PROPNAME_CHANNELBINDINGTYPE = "channelbindingtype";
+
public static final SystemProperty ITERATION_COUNT = SystemProperty.Builder.ofType(Integer.class)
.setKey("sasl.scram-sha-1.iteration-count")
.setDefaultValue(ScramUtils.DEFAULT_ITERATION_COUNT)
@@ -103,20 +112,44 @@ public synchronized static String getServerSecretForNonExistentUsers()
CLIENT_FIRST_MESSAGE = Pattern.compile("^(([pny])=?([^,]*),([^,]*),)(m?=?[^,]*,?n=([^,]*),r=([^,]*),?.*)$"),
CLIENT_FINAL_MESSAGE = Pattern.compile("(c=([^,]*),r=([^,]*)),p=(.*)$");
+ private final ChannelBindingProviderManager channelBindingProviderManager;
+ private final Set serverSupportedSaslMechanismNames;
+
+ private final boolean isPlusMechanism;
+ private final Map props;
private String username;
private State state = State.INITIAL;
private String nonce;
private String serverFirstMessage;
private String clientFirstMessageBare;
- private SecureRandom random = new SecureRandom();
+ private final SecureRandom random = new SecureRandom();
+ private byte[] expectedChannelBindingPayloadInFinalClientMessage;
+ private String gs2CbindName;
private enum State {
INITIAL,
IN_PROGRESS,
COMPLETE;
}
-
- public ScramSha1SaslServer() {
+
+ public ScramSha1SaslServer(final boolean isPlusMechanism, final Map props)
+ {
+ this.isPlusMechanism = isPlusMechanism;
+ this.props = props;
+ this.channelBindingProviderManager = ChannelBindingProviderManager.getInstance();
+ this.serverSupportedSaslMechanismNames = SASLAuthentication.getSupportedMechanisms();
+ }
+
+ /**
+ * Constructor for testing purposes.
+ */
+ @VisibleForTesting
+ ScramSha1SaslServer(final boolean isPlusMechanism, final Map props, final ChannelBindingProviderManager channelBindingProviderManager, final Set serverSupportedSaslMechanismNames)
+ {
+ this.isPlusMechanism = isPlusMechanism;
+ this.props = props;
+ this.channelBindingProviderManager = channelBindingProviderManager;
+ this.serverSupportedSaslMechanismNames = serverSupportedSaslMechanismNames;
}
/**
@@ -126,7 +159,7 @@ public ScramSha1SaslServer() {
*/
@Override
public String getMechanismName() {
- return "SCRAM-SHA-1";
+ return isPlusMechanism ? "SCRAM-SHA-1-PLUS" : "SCRAM-SHA-1";
}
/**
@@ -172,6 +205,7 @@ public byte[] evaluateResponse(final byte[] response) throws SaslException {
challenge = new byte[0];
break;
}
+ throw new SaslException("Unexpected response after authentication completed");
default:
throw new SaslException("No response expected in state " + state);
@@ -194,13 +228,76 @@ private byte[] generateServerFirstMessage(final byte[] response) throws SaslExce
if (!m.matches()) {
throw new SaslException("Invalid first client message");
}
+ final byte[] gs2_header = extractRawGS2Header(response); // Using raw header to prevent any normalization issues that might pop up when using something like: gs2Header.getBytes(StandardCharsets.UTF_8);
// String gs2Header = m.group(1);
-// String gs2CbindFlag = m.group(2);
-// String gs2CbindName = m.group(3);
+ String gs2CbindFlag = m.group(2);
+ gs2CbindName = m.group(3);
// String authzId = m.group(4);
clientFirstMessageBare = m.group(5);
username = m.group(6);
String clientNonce = m.group(7);
+
+ if (username == null || username.isEmpty()) {
+ throw new SaslException("Invalid first client message: Username cannot be empty");
+ }
+ if (clientNonce == null || clientNonce.isEmpty()) {
+ throw new SaslException("Invalid first client message: Client nonce cannot be empty");
+ }
+
+ // https://www.rfc-editor.org/rfc/rfc5802.html#section-6: If the flag is set to "y" and the server supports
+ // channel binding, the server MUST fail authentication. This is because if the client sets the channel binding
+ // flag to "y", then the client must have believed that the server did not support channel binding -- if the
+ // server did in fact support channel binding, then this is an indication that there has been a downgrade attack
+ // (e.g., an attacker changed the server's mechanism list to exclude the -PLUS suffixed SCRAM mechanism name(s)).
+ final boolean clientSupportsChannelBindingButThinksServerDoesNot = "y".equals(gs2CbindFlag);
+ final boolean serverSupportsChannelBinding = serverSupportedSaslMechanismNames.stream().anyMatch(mechanism -> mechanism.endsWith("-PLUS"));
+ if (clientSupportsChannelBindingButThinksServerDoesNot && serverSupportsChannelBinding) {
+ throw new SaslException("Client supports channel binding, but thinks the server does not (while it does). Rejecting authentication to prevent downgrade attack.");
+ }
+
+ final boolean clientRequiresChannelBinding = "p".equals(gs2CbindFlag);
+ if (clientRequiresChannelBinding && !isPlusMechanism) {
+ throw new SaslException("Client requires channel binding, but is not using a -PLUS mechanism. Rejecting authentication.");
+ }
+
+ if (isPlusMechanism)
+ {
+ if (!clientRequiresChannelBinding) {
+ throw new SaslException("Channel binding required for -PLUS. Rejecting authentication.");
+ }
+
+ if (!serverSupportsChannelBinding) {
+ throw new SaslException("Client requires channel binding, but server does not support channel binding. Rejecting authentication.");
+ }
+
+ // https://www.rfc-editor.org/rfc/rfc5802.html#section-6: If the channel binding flag was "p" and the server
+ // does not support the indicated channel binding type, then the server MUST fail authentication.
+ if (gs2CbindName == null || gs2CbindName.isEmpty() || !channelBindingProviderManager.supportsChannelBinding(gs2CbindName)) {
+ throw new SaslException("Client requires channel binding, but server does not support the indicated channel binding type '" + gs2CbindName + "'. Rejecting authentication.");
+ }
+
+ // Prepare channel binding data.
+ final LocalSession session = (LocalSession) props.get(LocalSession.class.getCanonicalName());
+ if (session == null || session.getConnection() == null) {
+ throw new SaslException("Local session not found in properties. Rejecting authentication.");
+ }
+ final Optional channelBindingData = session.getConnection().getChannelBindingData(gs2CbindName);
+ if (channelBindingData.isEmpty()) {
+ Log.debug("Unable to retrieve channel binding data for '{}'. Rejecting authentication.", gs2CbindName);
+ throw new SaslException("Unable to retrieve channel binding data for '" + gs2CbindName + "'. Rejecting authentication.");
+ }
+
+ // In the final client message, we expect to find a combination of the gs2 header and channel binding data.
+ final byte[] cb_data = channelBindingData.get();
+ expectedChannelBindingPayloadInFinalClientMessage = new byte[gs2_header.length + cb_data.length];
+ System.arraycopy(gs2_header, 0, expectedChannelBindingPayloadInFinalClientMessage, 0 , gs2_header.length);
+ System.arraycopy(cb_data, 0, expectedChannelBindingPayloadInFinalClientMessage, gs2_header.length, cb_data.length);
+ } else {
+ // If this is _not_ a -PLUS mechanism, we still need to verify the channel binding payload in the final client message.
+ // In that case, it should not have trailing channel binding data.
+ expectedChannelBindingPayloadInFinalClientMessage = gs2_header;
+ }
+
nonce = clientNonce + UUID.randomUUID().toString();
serverFirstMessage = String.format("r=%s,s=%s,i=%d", nonce, DatatypeConverter.printBase64Binary(getOrCreateSalt(username)),
@@ -208,6 +305,41 @@ private byte[] generateServerFirstMessage(final byte[] response) throws SaslExce
return serverFirstMessage.getBytes(StandardCharsets.UTF_8);
}
+ /**
+ * Extracts the raw GS2 header from a SCRAM client-first-message byte array.
+ *
+ * The GS2 header is defined in RFC 5802 as:
+ *
+ * gs2-header = gs2-cbind-flag "," [authzid] ","
+ *
+ * and always terminates with a trailing comma.
+ *
+ * This method performs a byte-level scan of the input and returns a copy of the original byte array from index
+ * {@code 0} up to and including the second comma (i.e., the full GS2 header including its trailing comma).
+ *
+ * No character decoding or normalization is performed. This ensures that the returned GS2 header is byte-for-
+ * byte identical to the original input, which is required for correct SCRAM-SHA-1-PLUS channel binding validation.
+ *
+ * @param data the raw SCRAM client-first-message bytes
+ * @return a byte array containing the complete GS2 header including the trailing comma
+ * @throws SaslException if the input does not contain a valid GS2 header
+ */
+ @VisibleForTesting
+ static byte[] extractRawGS2Header(final byte[] data) throws SaslException
+ {
+ // The GS2 header ends at the second comma.
+ int commaCount = 0;
+ for (int i = 0; i < data.length; i++) {
+ if (data[i] == ',') {
+ commaCount++;
+ if (commaCount == 2) {
+ return Arrays.copyOfRange(data, 0, i+1); // +1 to include the comma itself.
+ }
+ }
+ }
+ throw new SaslException("Invalid GS2 header format");
+ }
+
/**
* Final response returns the server signature.
*/
@@ -218,13 +350,34 @@ private byte[] generateServerFinalMessage(final byte[] response) throws SaslExce
throw new SaslException("Invalid client final message");
}
- String clientFinalMessageWithoutProof = m.group(1);
-// String channelBinding = m.group(2);
- String clientNonce = m.group(3);
- String proof = m.group(4);
+ // client-final-message regex: (c=([^,]*),r=([^,]*)),p=(.*)$")
+ final String clientFinalMessageWithoutProof = m.group(1); // (c=([^,]*),r=([^,]*))
+ final String channelBinding = m.group(2); // c=([^,]*)
+ final String clientNonce = m.group(3); // r=([^,]*)
+ final String proof = m.group(4); // p=(.*)
+ if (proof == null || proof.isEmpty()) {
+ throw new SaslException("Invalid client final message: missing proof attribute");
+ }
+
+ if (channelBinding == null || channelBinding.isEmpty()) {
+ throw new SaslException("Invalid client final message: missing channel binding attribute");
+ }
+
+ if (clientNonce == null || clientNonce.isEmpty()) {
+ throw new SaslException("Invalid client final message: missing nonce attribute");
+ }
+
+ // Verify nonce: RFC 5802 §5: must equal client_nonce (from initial client response) + server_nonce (from initial server response)
if (!nonce.equals(clientNonce)) { // Constant-time operation is important for keys, not for public protocol values like nonces.
- throw new SaslException("Client final message has incorrect nonce value");
+ // Possible replay or tampering
+ throw new SaslException("Invalid client final message: incorrect nonce attribute value");
+ }
+
+ // Verify channel binding payload.
+ final byte[] decodedChannelBinding = DatatypeConverter.parseBase64Binary(channelBinding);
+ if (!Arrays.equals(expectedChannelBindingPayloadInFinalClientMessage, decodedChannelBinding)) {
+ throw new SaslException("Invalid client final message: channel binding payload does not match expected payload");
}
try {
@@ -237,6 +390,9 @@ private byte[] generateServerFinalMessage(final byte[] response) throws SaslExce
byte[] clientKey = clientSignature.clone();
byte[] decodedProof = DatatypeConverter.parseBase64Binary(proof);
+ if (decodedProof.length != clientKey.length) {
+ throw new SaslException("Invalid proof length: expected " + clientKey.length + " bytes, got " + decodedProof.length);
+ }
for (int i = 0; i < clientKey.length; i++) {
clientKey[i] ^= decodedProof[i];
}
@@ -325,6 +481,8 @@ public Object getNegotiatedProperty(String propName) {
if (isComplete()) {
if (propName.equals(Sasl.QOP)) {
return "auth";
+ } else if (isPlusMechanism && propName.equals(PROPNAME_CHANNELBINDINGTYPE)) {
+ return gs2CbindName;
} else {
return null;
}
@@ -343,6 +501,11 @@ public Object getNegotiatedProperty(String propName) {
@Override
public void dispose() throws SaslException {
username = null;
+ nonce = null;
+ serverFirstMessage = null;
+ clientFirstMessageBare = null;
+ expectedChannelBindingPayloadInFinalClientMessage = null;
+ gs2CbindName = null;
state = State.INITIAL;
}
diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalClientSession.java b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalClientSession.java
index fb97cdbe5a..9c854d4e62 100644
--- a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalClientSession.java
+++ b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalClientSession.java
@@ -40,6 +40,7 @@
import org.jivesoftware.util.LocaleUtils;
import org.jivesoftware.util.StringUtils;
import org.jivesoftware.util.cache.Cache;
+import org.jivesoftware.util.channelbinding.ChannelBindingProviderManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xmlpull.v1.XmlPullParser;
@@ -294,6 +295,7 @@ public static LocalClientSession createSession(String serverName, XmlPullParser
// Include available SASL Mechanisms
final Element saslMechanisms = SASLAuthentication.getSASLMechanisms(session);
if (saslMechanisms != null) {
+ ChannelBindingProviderManager.getInstance().getSASLChannelBindingTypeCapabilityElement(saslMechanisms).ifPresent(features::add);
features.add(saslMechanisms);
}
// Include Stream features
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 b80ab56466..bf24bbaa7d 100644
--- a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalIncomingServerSession.java
+++ b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalIncomingServerSession.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2005-2008 Jive Software, 2016-2025 Ignite Realtime Foundation. All rights reserved.
+ * Copyright (C) 2005-2008 Jive Software, 2016-2026 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.
@@ -29,6 +29,7 @@
import org.jivesoftware.util.CertificateManager;
import org.jivesoftware.util.StreamErrorException;
import org.jivesoftware.util.StringUtils;
+import org.jivesoftware.util.channelbinding.ChannelBindingProviderManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xmlpull.v1.XmlPullParser;
@@ -189,6 +190,7 @@ public static LocalIncomingServerSession createSession(String serverName, XmlPul
// Include available SASL Mechanisms
final Element saslMechanisms = SASLAuthentication.getSASLMechanisms(session);
if (saslMechanisms != null) {
+ ChannelBindingProviderManager.getInstance().getSASLChannelBindingTypeCapabilityElement(saslMechanisms).ifPresent(features::add);
features.add(saslMechanisms);
}
diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandler.java b/xmppserver/src/main/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandler.java
index 68ee0039ba..f2c8fc7c56 100644
--- a/xmppserver/src/main/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandler.java
+++ b/xmppserver/src/main/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandler.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 Ignite Realtime Foundation. All rights reserved.
+ * Copyright (C) 2023-2026 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.
@@ -29,6 +29,7 @@
import org.jivesoftware.openfire.session.LocalClientSession;
import org.jivesoftware.openfire.session.Session;
import org.jivesoftware.util.StreamErrorException;
+import org.jivesoftware.util.channelbinding.ChannelBindingProviderManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xmlpull.v1.XmlPullParser;
@@ -166,6 +167,7 @@ private void sendStreamFeatures() {
// Include available SASL Mechanisms
final Element saslMechanisms = SASLAuthentication.getSASLMechanisms(session);
if (saslMechanisms != null) {
+ ChannelBindingProviderManager.getInstance().getSASLChannelBindingTypeCapabilityElement(saslMechanisms).ifPresent(features::add);
features.add(saslMechanisms);
}
}
diff --git a/xmppserver/src/main/java/org/jivesoftware/util/CertificateManager.java b/xmppserver/src/main/java/org/jivesoftware/util/CertificateManager.java
index 2e163221df..03fb5bbadf 100644
--- a/xmppserver/src/main/java/org/jivesoftware/util/CertificateManager.java
+++ b/xmppserver/src/main/java/org/jivesoftware/util/CertificateManager.java
@@ -628,7 +628,7 @@ protected static GeneralNames getSubjectAlternativeNames( Set sanDnsName
}
/**
- * Finds all values that aught to be added as a Subject Alternate Name of the dnsName type to a certificate that
+ * Finds all values that ought to be added as a Subject Alternate Name of the dnsName type to a certificate that
* identifies this XMPP server.
*
* @return A set of names, possibly empty, never null.
diff --git a/xmppserver/src/main/java/org/jivesoftware/util/cache/DefaultLocalCacheStrategy.java b/xmppserver/src/main/java/org/jivesoftware/util/cache/DefaultLocalCacheStrategy.java
index 059b4f1954..a91faf8006 100644
--- a/xmppserver/src/main/java/org/jivesoftware/util/cache/DefaultLocalCacheStrategy.java
+++ b/xmppserver/src/main/java/org/jivesoftware/util/cache/DefaultLocalCacheStrategy.java
@@ -24,7 +24,7 @@
import java.util.Map;
/**
- * CacheFactoryStrategy for use in Openfire. It creates and manages local caches, and it's cluster
+ * CacheFactoryStrategy for use in Openfire. It creates and manages local caches, and its cluster
* related method implementations do nothing.
*
* @see Cache
diff --git a/xmppserver/src/main/java/org/jivesoftware/util/channelbinding/ChannelBindingProvider.java b/xmppserver/src/main/java/org/jivesoftware/util/channelbinding/ChannelBindingProvider.java
new file mode 100644
index 0000000000..67f511f841
--- /dev/null
+++ b/xmppserver/src/main/java/org/jivesoftware/util/channelbinding/ChannelBindingProvider.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2026 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.util.channelbinding;
+
+import javax.annotation.Nonnull;
+import javax.net.ssl.SSLEngine;
+import java.util.Optional;
+
+/**
+ * Provides a mechanism to extract channel binding data of a specific type from an SSL engine.
+ *
+ * Implementations of this interface attempt to obtain the channel binding value as defined in relevant RFCs
+ * from a given SSL session, for the requested channel binding type (label). The availability and method of extraction
+ * may depend on the underlying TLS provider, JDK version, or presence of third-party libraries.
+ *
+ * @see RFC 5929: Channel Bindings for TLS
+ * @see RFC 5705: Keying Material Exporters for Transport Layer Security (TLS)
+ * @see RFC 9266: Channel Bindings for TLS 1.3
+ */
+public interface ChannelBindingProvider
+{
+ /**
+ * Returns the RFC-defined unique prefix for the channel binding type this provider supports (e.g., "tls-exporter",
+ * "tls-server-end-point").
+ *
+ * Note that these values are case-sensitive and must match exactly as defined in the respective RFCs.
+ *
+ * @return the channel binding type unique prefix (never null or empty)
+ */
+ String getType();
+
+ /**
+ * Attempts to extract the channel binding data from the provided SSL session.
+ *
+ * The returned value, if present, is the channel binding data as specified by the RFC for this provider's type.
+ * If the session or provider does not support this operation, an empty Optional is returned.
+ *
+ * Callers should treat returned arrays as immutable.
+ *
+ * @param engine the SSL engine from which to extract channel binding data (must not be null)
+ * @return an Optional containing the channel binding data, or empty if unavailable or unsupported
+ */
+ Optional getChannelBinding(@Nonnull final SSLEngine engine);
+}
diff --git a/xmppserver/src/main/java/org/jivesoftware/util/channelbinding/ChannelBindingProviderManager.java b/xmppserver/src/main/java/org/jivesoftware/util/channelbinding/ChannelBindingProviderManager.java
new file mode 100644
index 0000000000..b114ba1ca4
--- /dev/null
+++ b/xmppserver/src/main/java/org/jivesoftware/util/channelbinding/ChannelBindingProviderManager.java
@@ -0,0 +1,235 @@
+/*
+ * Copyright (C) 2026 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.util.channelbinding;
+
+import com.google.common.annotations.VisibleForTesting;
+import org.dom4j.DocumentHelper;
+import org.dom4j.Element;
+import org.dom4j.Namespace;
+import org.dom4j.QName;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nonnull;
+import javax.net.ssl.SSLEngine;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Manages a set of providers that can extract channel binding data of various types from SSL engines.
+ *
+ * This class offers a best-effort mechanism to obtain channel binding values as defined in RFC 5705, RFC 5929, RFC 9266, etc.
+ * It dynamically detects, at runtime, whether the underlying TLS implementation supports exporting keying material for the
+ * requested channel binding type, without requiring a hard dependency on any particular provider or JDK version. Providers
+ * are tried in order of registration until one succeeds or all fail.
+ *
+ * @author Guus der Kinderen, guus@goodbytes.nl
+ * @see RFC 5929: Channel Bindings for TLS
+ * @see RFC 5705: Keying Material Exporters for Transport Layer Security (TLS)
+ * @see RFC 9266: Channel Bindings for TLS 1.3
+ */
+public class ChannelBindingProviderManager
+{
+ private static final Logger Log = LoggerFactory.getLogger(ChannelBindingProviderManager.class);
+
+ private static final ChannelBindingProviderManager INSTANCE = new ChannelBindingProviderManager();
+
+ static {
+ INSTANCE.addProvider(new TlsServerEndPointChannelBindingProvider());
+ }
+
+ // Map from RFC-defined unique prefix to ordered list of providers for that type.
+ private final Map> providers = new ConcurrentHashMap<>();
+
+ /**
+ * Returns the singleton instance of the manager.
+ *
+ * @return the singleton ChannelBindingProviderManager instance
+ */
+ public static ChannelBindingProviderManager getInstance()
+ {
+ return INSTANCE;
+ }
+
+ /**
+ * Constructs a new manager instance. Intended primarily for testing; typical usage should prefer the singleton
+ * returned by getInstance().
+ */
+ @VisibleForTesting
+ public ChannelBindingProviderManager() // TODO: It is not ideal to have this test-only constructor be 'public', but that's currently required for ScramSha1SaslServerTest. Can this be refactored to avoid such wide access?
+ {
+ }
+
+ /**
+ * Registers a provider for its declared channel binding type (RFC-defined unique prefix) at the tail (end) of the
+ * list. Multiple providers can be registered for the same type; they are tried in registration order (head to tail).
+ *
+ * @param provider the provider to register
+ */
+ public void addProvider(@Nonnull final ChannelBindingProvider provider)
+ {
+ Objects.requireNonNull(provider, "provider must not be null");
+ final String prefix = provider.getType();
+ Objects.requireNonNull(prefix, "provider's type must not be null");
+ if (prefix.isEmpty()) {
+ throw new IllegalArgumentException("provider's type must not be empty");
+ }
+
+ Log.trace("Registering channel binding provider at tail: {} for prefix '{}'", provider.getClass().getName(), prefix);
+ providers.computeIfAbsent(prefix, k -> new CopyOnWriteArrayList<>()).add(provider);
+ }
+
+ /**
+ * Registers a provider for its declared channel binding type (RFC-defined unique prefix) at the head (start) of the
+ * list. Multiple providers can be registered for the same type; they are tried in registration order (head to tail).
+ *
+ * @param provider the provider to register
+ */
+ public void addProviderToHead(@Nonnull final ChannelBindingProvider provider)
+ {
+ Objects.requireNonNull(provider, "provider must not be null");
+ final String prefix = provider.getType();
+ Objects.requireNonNull(prefix, "provider's type must not be null");
+ if (prefix.isEmpty()) {
+ throw new IllegalArgumentException("provider's type must not be empty");
+ }
+
+ Log.trace("Registering channel binding provider at head: {} for prefix '{}'", provider.getClass().getName(), prefix);
+ providers.computeIfAbsent(prefix, k -> new CopyOnWriteArrayList<>()).add(0, provider);
+ }
+
+ /**
+ * Removes a specific provider instance for the given channel binding type prefix, if present.
+ *
+ * When multiple instances are registered, only the first instance is removed.
+ *
+ * @param provider the provider instance to remove
+ * @return if this manager contained the specified provider
+ */
+ public boolean removeProvider(@Nonnull final ChannelBindingProvider provider)
+ {
+ Objects.requireNonNull(provider, "provider must not be null");
+ final String prefix = provider.getType();
+ Objects.requireNonNull(prefix, "provider's type must not be null");
+ if (prefix.isEmpty()) {
+ throw new IllegalArgumentException("provider's type must not be empty");
+ }
+
+ Log.trace("Removing channel binding provider {} for prefix '{}'", provider.getClass().getName(), prefix);
+ final AtomicBoolean removed = new AtomicBoolean(false);
+ providers.computeIfPresent(prefix, (k, list) -> {
+ removed.set(list.remove(provider));
+ return list.isEmpty() ? null : list;
+ });
+ return removed.get();
+ }
+
+ /**
+ * Attempts to obtain the channel binding data for the given type prefix and SSL engine by delegating to registered
+ * providers in order. Returns the first successful result, or an empty Optional if none succeed.
+ *
+ * @param cbPrefix the RFC-defined unique prefix for the channel binding type (must not be null or empty)
+ * @param engine the SSL engine from which to extract channel binding data
+ * @return an Optional containing the channel binding data, or empty if unavailable
+ */
+ public Optional getChannelBinding(@Nonnull final String cbPrefix, @Nonnull final SSLEngine engine)
+ {
+ Objects.requireNonNull(cbPrefix, "cbPrefix must not be null");
+ if (cbPrefix.isEmpty()) {
+ throw new IllegalArgumentException("type must not be empty");
+ }
+ Objects.requireNonNull(engine, "engine must not be null");
+
+ Log.trace("Getting channel binding '{}' for engine: {}", cbPrefix, engine);
+ final List list = providers.get(cbPrefix);
+ if (list == null || list.isEmpty())
+ {
+ Log.debug("No channel binding provider registered for prefix '{}'", cbPrefix);
+ return Optional.empty();
+ }
+ for (final ChannelBindingProvider provider : list) {
+ try {
+ Log.trace("Trying provider: {}", provider.getClass().getName());
+ final Optional channelBindingData = provider.getChannelBinding(engine);
+ if (channelBindingData.isPresent()) {
+ Log.debug("Channel binding '{}' found for engine: {} by provider {}", cbPrefix, engine, provider.getClass().getName());
+ return channelBindingData;
+ }
+ } catch (Exception t) {
+ Log.warn("Provider '{}' failed to obtain channel binding '{}' for engine: {}", provider.getClass().getName(), cbPrefix, engine, t);
+ }
+ }
+ Log.debug("No channel binding '{}' found for engine: {}", cbPrefix, engine);
+ return Optional.empty();
+ }
+
+ /**
+ * Checks if there is at least one provider registered for the given channel binding type prefix.
+ *
+ * @param cbPrefix the RFC-defined unique prefix for the channel binding type (must not be null or empty)
+ * @return true if at least one provider is registered for the prefix, false otherwise
+ */
+ public boolean supportsChannelBinding(@Nonnull final String cbPrefix)
+ {
+ Objects.requireNonNull(cbPrefix, "cbPrefix must not be null");
+ if (cbPrefix.isEmpty()) {
+ throw new IllegalArgumentException("cbPrefix must not be empty");
+ }
+
+ return !providers.getOrDefault(cbPrefix, List.of()).isEmpty();
+ }
+
+ /**
+ * Returns an unmodifiable set of all supported channel binding type prefixes for which at least one provider is registered.
+ *
+ * @return a set of RFC-defined unique prefixes for supported channel binding types
+ */
+ public Set getSupportedChannelBindingTypes()
+ {
+ return Collections.unmodifiableSet(providers.keySet());
+ }
+
+ /**
+ * Returns an XML element that describes the supported SASL channel binding types, if applicable.
+ *
+ * This method inspects the provided SASL mechanisms element. If at least one mechanism ends with "-PLUS"
+ * and the server supports one or more channel binding types, it returns an element that advertises these types.
+ * Otherwise, it returns an empty Optional.
+ *
+ * @param saslMechanisms The XML element containing SASL mechanisms to inspect.
+ * @return An Optional containing the capability element if channel binding types should be advertised, or empty otherwise.
+ * @see XEP-0440: SASL Channel-Binding Type Capability
+ */
+ public Optional getSASLChannelBindingTypeCapabilityElement(@Nonnull final Element saslMechanisms)
+ {
+ if (saslMechanisms.elements("mechanism").stream().noneMatch(mech -> mech.getText().endsWith("-PLUS"))) {
+ return Optional.empty();
+ }
+
+ final Set supportedChannelBindingTypes = this.getSupportedChannelBindingTypes();
+ if (supportedChannelBindingTypes.isEmpty()) {
+ return Optional.empty();
+ }
+
+ final Element result = DocumentHelper.createElement(new QName("sasl-channel-binding", new Namespace("", "urn:xmpp:sasl-cb:0")));
+ for (final String channelBindingType : supportedChannelBindingTypes) {
+ result.addElement("channel-binding").addAttribute("type", channelBindingType);
+ }
+ return Optional.of(result);
+ }
+}
diff --git a/xmppserver/src/main/java/org/jivesoftware/util/channelbinding/ChannelBindingType.java b/xmppserver/src/main/java/org/jivesoftware/util/channelbinding/ChannelBindingType.java
new file mode 100644
index 0000000000..3c3498aa05
--- /dev/null
+++ b/xmppserver/src/main/java/org/jivesoftware/util/channelbinding/ChannelBindingType.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2026 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.util.channelbinding;
+
+/**
+ * Enumerates supported channel binding types.
+ *
+ * @see RFC 5705: Keying Material Exporters for Transport Layer Security (TLS)
+ * @see RFC 5929: Channel Bindings for TLS
+ * @see RFC 9266: Channel Bindings for TLS 1.3
+ */
+public enum ChannelBindingType
+{
+ /**
+ * tls-exporter: TLS exporter-based channel binding (RFC 5705, RFC 9266).
+ */
+ TLS_EXPORTER("tls-exporter"),
+
+ /**
+ * tls-server-end-point: server certificate hash channel binding (RFC 5929).
+ */
+ TLS_SERVER_END_POINT("tls-server-end-point"),
+
+ /**
+ * tls-unique: TLS Finished message channel binding (RFC 5929, deprecated).
+ */
+ TLS_UNIQUE("tls-unique");
+
+ /**
+ * RFC-defined Channel-binding unique prefix
+ */
+ private final String prefix;
+
+ ChannelBindingType(String prefix) {
+ this.prefix = prefix;
+ }
+
+ public String getPrefix()
+ {
+ return prefix;
+ }
+}
diff --git a/xmppserver/src/main/java/org/jivesoftware/util/channelbinding/TlsServerEndPointChannelBindingProvider.java b/xmppserver/src/main/java/org/jivesoftware/util/channelbinding/TlsServerEndPointChannelBindingProvider.java
new file mode 100644
index 0000000000..ee110e8fbc
--- /dev/null
+++ b/xmppserver/src/main/java/org/jivesoftware/util/channelbinding/TlsServerEndPointChannelBindingProvider.java
@@ -0,0 +1,216 @@
+/*
+ * Copyright (C) 2026 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.util.channelbinding;
+
+import com.google.common.annotations.VisibleForTesting;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nonnull;
+import javax.net.ssl.SSLEngine;
+import javax.net.ssl.SSLPeerUnverifiedException;
+import javax.net.ssl.SSLSession;
+import java.security.AlgorithmParameters;
+import java.security.MessageDigest;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+import java.security.spec.PSSParameterSpec;
+import java.util.*;
+import java.util.regex.Pattern;
+
+/**
+ * Implementation of {@link ChannelBindingProvider} for the tls-server-end-point channel binding type (RFC 5929).
+ *
+ * This provider extracts channel binding data from a {@link SSLEngine}, using the hash of the server's certificate
+ * as specified by RFC 5929. The hash algorithm is chosen based on the certificate's signature algorithm.
+ *
+ * The channel binding data is always derived from the server certificate, regardless of which side computes it.
+ *
+ * @see RFC 5929: Channel Bindings for TLS
+ */
+public class TlsServerEndPointChannelBindingProvider implements ChannelBindingProvider
+{
+ private static final Logger Log = LoggerFactory.getLogger(TlsServerEndPointChannelBindingProvider.class);
+ private static final Set SERVER_END_POINT_WEAK_HASH_ALGORITHMS = Set.of("MD5", "SHA1", "SHA-1");
+ private static final String SERVER_END_POINT_FALLBACK_HASH_ALGORITHM = "SHA-256";
+ private static final Pattern ALGORITHM_NAME_PATTERN = Pattern.compile("^SHA(?!\\d+-)(\\d)");
+
+ @Override
+ public String getType()
+ {
+ return ChannelBindingType.TLS_SERVER_END_POINT.getPrefix();
+ }
+
+ /**
+ * Attempts to extract the channel binding data from the provided SSLEngine. This is typically the hash of the
+ * server's certificate. The hash algorithm is chosen based on the certificate's signature algorithm per
+ * RFC 5929 §4.1.
+ *
+ * The tls-server-end-point binding is always derived from the server certificate, regardless of which side computes
+ * it. To determine if the local entity is acting in server or client mode, the engine's #getUseClientMode() method
+ * is evaluated.
+ *
+ * @param engine the SSLEngine from which to extract channel binding data (must not be null)
+ * @return an Optional containing the channel binding data, or empty if unavailable or unsupported
+ */
+ @Override
+ public Optional getChannelBinding(@Nonnull final SSLEngine engine)
+ {
+ Objects.requireNonNull(engine, "engine must not be null");
+ final SSLSession session = engine.getSession();
+ try
+ {
+ // This binding requires the server certificate to be used, which is the local certificate when we're the
+ // server, but the peer's certificate when we're establishing a connection to a remote server (e.g. s2s).
+ final Certificate[] certs;
+ try {
+ certs = engine.getUseClientMode() ? session.getPeerCertificates() : session.getLocalCertificates();
+ } catch (SSLPeerUnverifiedException e) {
+ return Optional.empty();
+ }
+
+ // RFC 5929 specifies the end-entity certificate (first in chain)
+ if (certs == null || certs.length == 0 || !(certs[0] instanceof X509Certificate cert)) {
+ return Optional.empty();
+ }
+
+ final String hashAlg;
+ if ("1.2.840.113549.1.1.10".equals(cert.getSigAlgOID())) { // Use OID instead of name for PSS detection (more robust)
+ // RSASSA-PSS is effectively the only commonly used signature algorithm where the hash function is not
+ // encoded in the algorithm name; all other mainstream algorithms either include the hash in the name or
+ // (like Ed25519) use no hash at all, in which case returning "undefined" is correct per RFC 5929.
+ hashAlg = resolveServerEndPointHashAlgorithmPSS(cert);
+ } else {
+ hashAlg = resolveServerEndPointHashAlgorithm(cert.getSigAlgName());
+ }
+ if (hashAlg == null) {
+ Log.debug("TLS server end point channel binding is undefined for signature algorithm '{}' for session: {}", cert.getSigAlgName(), session);
+ return Optional.empty();
+ }
+ final MessageDigest md = MessageDigest.getInstance(hashAlg);
+ return Optional.of(md.digest(cert.getEncoded()));
+ } catch (final Exception e) {
+ Log.trace("Failed to compute TLS server end point channel binding for session: {}", session, e);
+ }
+ return Optional.empty();
+ }
+
+ /**
+ * Resolves the hash algorithm for a certificate using RSASSA-PSS signature algorithm.
+ *
+ * Extracts the hash algorithm from the PSS parameters of the certificate and normalizes it for use in TLS server
+ * end point channel binding. Returns null if the hash algorithm cannot be determined.
+ *
+ * @param cert the X509Certificate with RSASSA-PSS signature algorithm
+ * @return the normalized hash algorithm name, or null if unavailable
+ */
+ @VisibleForTesting
+ String resolveServerEndPointHashAlgorithmPSS(final X509Certificate cert)
+ {
+ final String pssHash = extractPssHashAlgorithm(cert);
+ if (pssHash == null) {
+ return null;
+ }
+ final String normalized = normalizeServerEndPointHashAlgorithmName(pssHash);
+ return substituteWeakServerEndPointHashAlgorithm(normalized);
+ }
+
+ /**
+ * Extracts the hash algorithm name from the RSASSA-PSS parameters of the given X509 certificate.
+ *
+ * @param cert the X509Certificate with RSASSA-PSS signature algorithm
+ * @return the hash algorithm name, or null if unavailable or parsing fails
+ */
+ @VisibleForTesting
+ String extractPssHashAlgorithm(X509Certificate cert)
+ {
+ try {
+ final byte[] params = cert.getSigAlgParams();
+ if (params == null) {
+ return null;
+ }
+ final AlgorithmParameters ap = AlgorithmParameters.getInstance("RSASSA-PSS");
+ ap.init(params);
+ final PSSParameterSpec spec = ap.getParameterSpec(PSSParameterSpec.class);
+ return spec.getDigestAlgorithm();
+ } catch (Exception e) {
+ Log.trace("Failed to parse RSASSA-PSS parameters", e);
+ return null;
+ }
+ }
+
+ /**
+ * Resolves the hash algorithm to use for TLS server end point channel binding per RFC 5929 §4.1:
+ *
+ *
+ * - MD5 or SHA-1 -> SHA-256
+ * - Any other single hash function -> that hash function
+ * - No hash function or multiple hash functions -> undefined (returns null)
+ *
+ *
+ * @param sigAlgName the certificate's signature algorithm name (e.g. {@code "SHA256withRSA"})
+ * @return the JCA hash algorithm name to use, or {@code null} if channel binding is undefined
+ * @see RFC 5929: Channel Bindings for TLS
+ */
+ @VisibleForTesting
+ String resolveServerEndPointHashAlgorithm(final String sigAlgName)
+ {
+ if (sigAlgName == null) {
+ return null;
+ }
+ final String[] parts = sigAlgName.split("(?i)with", 2);
+ if (parts.length != 2 || parts[0].isBlank() || parts[1].isBlank()) {
+ Log.trace("No hash function for signature algorithm '{}'", sigAlgName);
+ return null;
+ }
+ final String normalizedHash = normalizeServerEndPointHashAlgorithmName(parts[0].trim());
+ final String result = substituteWeakServerEndPointHashAlgorithm(normalizedHash);
+ Log.trace("Hash function for signature algorithm '{}': '{}'", sigAlgName, result);
+ return result;
+ }
+
+ /**
+ * Normalizes a hash algorithm name to its canonical JCA form for TLS server end point channel binding by inserting the dash that
+ * {@link java.security.cert.X509Certificate#getSigAlgName()} omits in SHA-2 family names. For example:
+ * {@code "SHA256"} becomes {@code "SHA-256"}, {@code "SHA512"} becomes {@code "SHA-512"}. Already-correct names
+ * ({@code "SHA-256"}, {@code "SHA3-256"}, {@code "MD5"}) are unchanged.
+ */
+ @VisibleForTesting
+ String normalizeServerEndPointHashAlgorithmName(final String name)
+ {
+ return ALGORITHM_NAME_PATTERN
+ .matcher(name.toUpperCase(Locale.ROOT))
+ .replaceAll("SHA-$1");
+ }
+
+ /**
+ * Substitutes weak hash algorithms (MD5, SHA-1) with SHA-256 for TLS server end point channel binding, per RFC 5929 §4.1.
+ *
+ * @param hashAlg the hash algorithm name
+ * @return SHA-256 if the input is a weak hash, otherwise the input hash algorithm
+ */
+ @VisibleForTesting
+ String substituteWeakServerEndPointHashAlgorithm(final String hashAlg)
+ {
+ if (SERVER_END_POINT_WEAK_HASH_ALGORITHMS.contains(hashAlg)) {
+ return SERVER_END_POINT_FALLBACK_HASH_ALGORITHM;
+ }
+ return hashAlg;
+ }
+}
+
+
diff --git a/xmppserver/src/main/webapp/session-details.jsp b/xmppserver/src/main/webapp/session-details.jsp
index e24ba039e4..e452d0f772 100644
--- a/xmppserver/src/main/webapp/session-details.jsp
+++ b/xmppserver/src/main/webapp/session-details.jsp
@@ -1,7 +1,7 @@
<%@ page contentType="text/html; charset=UTF-8" %>
<%--
-
- - Copyright (C) 2004-2008 Jive Software, 2017-2025 Ignite Realtime Foundation. All rights reserved.
+ - Copyright (C) 2004-2008 Jive Software, 2017-2026 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.
@@ -460,6 +460,16 @@
<% } %>
+ <% if (currentSess instanceof LocalSession && ((LocalSession) currentSess).getSessionData("ChannelBindingType") != null) { %>
+
+ |
+ :
+ |
+
+ <%=StringUtils.escapeHTMLTags(((LocalSession) currentSess).getSessionData("ChannelBindingType").toString())%>
+ |
+
+ <% } %>
diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/sasl/ScramSha1SaslServerFakeKeyTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/sasl/ScramSha1SaslServerFakeKeyTest.java
index 9cf7a7f84e..7b34c22b74 100644
--- a/xmppserver/src/test/java/org/jivesoftware/openfire/sasl/ScramSha1SaslServerFakeKeyTest.java
+++ b/xmppserver/src/test/java/org/jivesoftware/openfire/sasl/ScramSha1SaslServerFakeKeyTest.java
@@ -16,8 +16,11 @@
package org.jivesoftware.openfire.sasl;
import org.jivesoftware.Fixtures;
+import org.jivesoftware.util.channelbinding.ChannelBindingProviderManager;
import org.junit.jupiter.api.*;
import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
@@ -56,7 +59,7 @@ void fakeKeyIsDeterministicForSameInput()
{
// Setup test fixture
ScramSha1SaslServer.SERVER_SECRET_NONEXISTENT_USERS.setValue(SERVER_SECRET_1);
- final ScramSha1SaslServer server = new ScramSha1SaslServer();
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
// Execute system under test
final byte[] key1 = server.getOrFakeStoredKey(USERNAME1);
@@ -74,7 +77,7 @@ void fakeKeysDifferForDifferentUsernames()
{
// Setup test fixture
ScramSha1SaslServer.SERVER_SECRET_NONEXISTENT_USERS.setValue(SERVER_SECRET_1);
- final ScramSha1SaslServer server = new ScramSha1SaslServer();
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
// Execute system under test
final byte[] key1 = server.getOrFakeStoredKey(USERNAME1);
@@ -92,7 +95,7 @@ void fakeKeyChangesWhenServerSecretChanges()
{
// Setup test fixture
ScramSha1SaslServer.SERVER_SECRET_NONEXISTENT_USERS.setValue(SERVER_SECRET_1);
- final ScramSha1SaslServer server = new ScramSha1SaslServer();
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
// Execute system under test
final byte[] key1 = server.getOrFakeStoredKey(USERNAME1);
@@ -111,7 +114,7 @@ void fakeKeyHasExpectedLength()
{
// Setup test fixture
ScramSha1SaslServer.SERVER_SECRET_NONEXISTENT_USERS.setValue(SERVER_SECRET_1);
- final ScramSha1SaslServer server = new ScramSha1SaslServer();
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
// Execute system under test
final byte[] key = server.getOrFakeStoredKey(USERNAME1);
@@ -128,7 +131,7 @@ void storedAndServerKeysDifferForSameUsername()
{
// Setup test fixture
ScramSha1SaslServer.SERVER_SECRET_NONEXISTENT_USERS.setValue(SERVER_SECRET_1);
- final ScramSha1SaslServer server = new ScramSha1SaslServer();
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
// Execute system under test
final byte[] stored = server.getOrFakeStoredKey(USERNAME1);
diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/sasl/ScramSha1SaslServerSaltTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/sasl/ScramSha1SaslServerSaltTest.java
index 2d05c49dbc..6d58c9e319 100644
--- a/xmppserver/src/test/java/org/jivesoftware/openfire/sasl/ScramSha1SaslServerSaltTest.java
+++ b/xmppserver/src/test/java/org/jivesoftware/openfire/sasl/ScramSha1SaslServerSaltTest.java
@@ -18,11 +18,14 @@
import org.jivesoftware.Fixtures;
import org.jivesoftware.openfire.auth.AuthFactory;
import org.jivesoftware.openfire.user.UserNotFoundException;
+import org.jivesoftware.util.channelbinding.ChannelBindingProviderManager;
import org.junit.jupiter.api.*;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
@@ -87,7 +90,7 @@ void tearDown()
void fakeSaltIsDeterministicForNonExistentUser()
{
// Setup test fixture
- final ScramSha1SaslServer server = new ScramSha1SaslServer();
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
// Execute system under test
final byte[] salt1 = server.getOrCreateSalt(NON_EXISTENT_USER);
@@ -104,7 +107,7 @@ void fakeSaltIsDeterministicForNonExistentUser()
void fakeSaltIsDifferentFromRealUserSalt()
{
// Setup test fixture
- final ScramSha1SaslServer server = new ScramSha1SaslServer();
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
// Execute system under test
final byte[] fakeSalt = server.getOrCreateSalt(NON_EXISTENT_USER);
@@ -121,7 +124,7 @@ void fakeSaltIsDifferentFromRealUserSalt()
void fakeSaltChangesWhenServerSecretChanges()
{
// Setup test fixture
- final ScramSha1SaslServer server = new ScramSha1SaslServer();
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
// Execute system under test
final byte[] salt1 = server.getOrCreateSalt(NON_EXISTENT_USER);
@@ -139,7 +142,7 @@ void fakeSaltChangesWhenServerSecretChanges()
void realUserSaltIsConsistent()
{
// Setup test fixture
- final ScramSha1SaslServer server = new ScramSha1SaslServer();
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
// Execute system under test
final byte[] salt1 = server.getOrCreateSalt(EXISTENT_USER);
diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/sasl/ScramSha1SaslServerTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/sasl/ScramSha1SaslServerTest.java
index 083816e748..595b669b77 100644
--- a/xmppserver/src/test/java/org/jivesoftware/openfire/sasl/ScramSha1SaslServerTest.java
+++ b/xmppserver/src/test/java/org/jivesoftware/openfire/sasl/ScramSha1SaslServerTest.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023-2024 Ignite Realtime Foundation. All rights reserved.
+ * Copyright (C) 2023-2026 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.
@@ -17,27 +17,45 @@
import org.apache.commons.codec.digest.HmacAlgorithms;
import org.apache.commons.codec.digest.HmacUtils;
+import org.jivesoftware.openfire.Connection;
import org.jivesoftware.openfire.auth.AuthFactory;
+import org.jivesoftware.openfire.session.LocalSession;
import org.jivesoftware.util.StringUtils;
+import org.jivesoftware.util.channelbinding.ChannelBindingProvider;
+import org.jivesoftware.util.channelbinding.ChannelBindingProviderManager;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
-
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
+import javax.security.sasl.Sasl;
import javax.security.sasl.SaslException;
import javax.xml.bind.DatatypeConverter;
import java.nio.charset.StandardCharsets;
import java.security.spec.KeySpec;
+import java.util.Arrays;
import java.util.Base64;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
-import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
/**
* Unit tests that verify the implementation of {@link ScramSha1SaslServer}
@@ -83,7 +101,7 @@ public void testSuccess() throws Exception
authFactory.when(() -> AuthFactory.getServerKey(any())).thenReturn(DatatypeConverter.printBase64Binary(StringUtils.decodeHex("0fe09258b3ac852ba502cc62ba903eaacdbf7d31")));
// Setup test fixture: prepare initial client message.
- final ScramSha1SaslServer server = new ScramSha1SaslServer();
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
final byte[] initialMessage = ("n,,n=user,r=" + hardCodedClientNonce).getBytes(StandardCharsets.UTF_8);
// Execute system under test: getting the first server message.
@@ -143,4 +161,711 @@ public void testSuccess() throws Exception
fail("Authentication should not fail (but it did)");
}
}
+
+ /**
+ * Implements a successful SCRAM-SHA-1-PLUS exchange with channel binding.
+ */
+ @Test
+ public void testSuccessPlus() throws Exception
+ {
+ // Setup test fixture
+ final SecretKeyFactory HmacSHA1Factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
+ final String hardCodedClientNonce = "fyko+d2lbbFgONRv9qkxdawL";
+ final String hardCodedSalt = "QSXCR+Q6sek8bf92";
+ final int hardCodedIterations = 4096;
+ final String hardCodedPassword = "pencil";
+ final String hardCodedClientKey = "Client Key";
+ final String hardCodedServerKey = "Server Key";
+ final String channelBindingType = "tls-server-end-point";
+ final byte[] channelBindingData = "mocked-channel-binding-data".getBytes(StandardCharsets.UTF_8);
+
+ final ChannelBindingProviderManager channelBindingProviderManager = new ChannelBindingProviderManager();
+ final ChannelBindingProvider serverEndPointProvider = mock(ChannelBindingProvider.class);
+ when(serverEndPointProvider.getType()).thenReturn("tls-server-end-point");
+ when(serverEndPointProvider.getChannelBinding(any())).thenReturn(Optional.of(channelBindingData));
+
+ final LocalSession mockSession = mock(LocalSession.class);
+ final Connection mockConnection = mock(Connection.class);
+ when(mockConnection.getChannelBindingData(channelBindingType)).thenReturn(Optional.of(channelBindingData));
+ when(mockSession.getConnection()).thenReturn(mockConnection);
+
+ channelBindingProviderManager.addProvider(serverEndPointProvider);
+
+ authFactory.when(() -> AuthFactory.getSalt(any())).thenReturn(hardCodedSalt);
+ authFactory.when(() -> AuthFactory.getIterations(any())).thenReturn(hardCodedIterations);
+ authFactory.when(() -> AuthFactory.getPassword(any())).thenReturn(hardCodedPassword);
+ authFactory.when(() -> AuthFactory.getStoredKey(any())).thenReturn(DatatypeConverter.printBase64Binary(StringUtils.decodeHex("e9d94660c39d65c38fbad91c358f14da0eef2bd6")));
+ authFactory.when(() -> AuthFactory.getServerKey(any())).thenReturn(DatatypeConverter.printBase64Binary(StringUtils.decodeHex("0fe09258b3ac852ba502cc62ba903eaacdbf7d31")));
+
+ // Setup test fixture: prepare initial client message with channel binding.
+ final Map props = new HashMap<>();
+ props.put(LocalSession.class.getCanonicalName(), mockSession);
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(true, props, channelBindingProviderManager, Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
+ final String gs2Header = "p=" + channelBindingType + ",,";
+ final byte[] initialMessage = (gs2Header + "n=user,r=" + hardCodedClientNonce).getBytes(StandardCharsets.UTF_8);
+
+ // Execute system under test: getting the first server message.
+ final String firstServerResponse = new String(server.evaluateResponse(initialMessage), StandardCharsets.UTF_8);
+
+ // Verify result (first server message should match a pattern, and contain a number of properties)
+ final Matcher firstServerResponseMatcher = Pattern.compile("r=([^,]*),s=([^,]*),i=(.*)$").matcher(firstServerResponse);
+ assertTrue(firstServerResponseMatcher.matches(), "First server message does not match expected pattern.");
+ final String serverNonce = firstServerResponseMatcher.group(1);
+ assertTrue(serverNonce != null && !serverNonce.isBlank(), "First server message should contain a non-empty server nonce (but did not)");
+ assertTrue(serverNonce.startsWith(hardCodedClientNonce), "First server message should contain a server nonce that starts with the client nonce, but did not.");
+
+ byte[] salt = null;
+ try {
+ salt = DatatypeConverter.parseBase64Binary(firstServerResponseMatcher.group(2));
+ assertEquals(hardCodedSalt, firstServerResponseMatcher.group(2), "First server message should include the 'salt' value configured for this unit test (but did not)");
+ } catch (IllegalArgumentException e) {
+ fail("First server message should contain a valid 'salt' value (but did not).");
+ }
+
+ int iterations = -1;
+ try {
+ iterations = Integer.parseInt(firstServerResponseMatcher.group(3));
+ assertEquals(hardCodedIterations, iterations, "First server message should include the 'iterations' value configured for this unit test (but did not)");
+ } catch (NumberFormatException e) {
+ fail("First server message should contain a valid 'iterations' value (but did not).");
+ }
+
+ // Setup test fixture: prepare second client message with channel binding.
+ final byte[] gs2HeaderBytes = gs2Header.getBytes(StandardCharsets.UTF_8);
+ final byte[] cbindInput = new byte[gs2HeaderBytes.length + channelBindingData.length];
+ System.arraycopy(gs2HeaderBytes, 0, cbindInput, 0, gs2HeaderBytes.length);
+ System.arraycopy(channelBindingData, 0, cbindInput, gs2HeaderBytes.length, channelBindingData.length);
+ final String clientFinalMessageBare = "c=" + Base64.getEncoder().encodeToString(cbindInput) + ",r=" + serverNonce;
+
+ final KeySpec saltedPasswordSpec = new PBEKeySpec(hardCodedPassword.toCharArray(), salt, iterations, 20*8);
+ final byte[] saltedPassword = HmacSHA1Factory.generateSecret(saltedPasswordSpec).getEncoded();
+
+ final byte[] clientKey = new HmacUtils(HmacAlgorithms.HMAC_SHA_1, saltedPassword).hmac(hardCodedClientKey);
+ final byte[] storedKey = StringUtils.decodeHex(StringUtils.hash(clientKey, "SHA-1"));
+ final String authMessage = new String(initialMessage, StandardCharsets.UTF_8).substring(gs2Header.length()) + "," + firstServerResponse + "," + clientFinalMessageBare;
+
+ final byte[] clientSignature = new HmacUtils(HmacAlgorithms.HMAC_SHA_1, storedKey).hmac(authMessage);
+ final byte[] clientProof = new byte[clientKey.length];
+ for (int i=0; i
+ ScramSha1SaslServer.extractRawGS2Header(input));
+ }
+
+ /**
+ * Verifies that the minimal valid GS2 header is handled correctly.
+ */
+ @Test
+ void handlesMinimalValidGs2Header() throws Exception
+ {
+ // Setup test fixture
+ final byte[] input = "n,,rest".getBytes(StandardCharsets.UTF_8);
+
+ // Execute system under test
+ final byte[] result = ScramSha1SaslServer.extractRawGS2Header(input);
+
+ // Verify result
+ assertEquals("n,,", new String(result, StandardCharsets.UTF_8));
+ }
+
+ /**
+ * Ensures GS2 header extraction stops at the second comma only.
+ */
+ @Test
+ void stopsAtSecondComma_only() throws Exception
+ {
+ // Setup test fixture
+ final byte[] input = "p=tls,,n=user,r=abc,extra,stuff".getBytes(StandardCharsets.UTF_8);
+
+ // Execute system under test
+ final byte[] result = ScramSha1SaslServer.extractRawGS2Header(input);
+
+ // Verify result
+ assertEquals("p=tls,,", new String(result, StandardCharsets.UTF_8));
+ }
+
+ /**
+ * Verifies the mechanism name for non-PLUS instances.
+ */
+ @Test
+ void getMechanismName_returnsScramSha1_forNonPlusMechanism()
+ {
+ // Setup test fixture
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
+
+ // Execute system under test
+ final String mechanismName = server.getMechanismName();
+
+ // Verify result
+ assertEquals("SCRAM-SHA-1", mechanismName, "Non-PLUS mechanism should return 'SCRAM-SHA-1' as its name");
+ }
+
+ /**
+ * Verifies the mechanism name for PLUS instances.
+ */
+ @Test
+ void getMechanismName_returnsScramSha1Plus_forPlusMechanism()
+ {
+ // Setup test fixture
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(true, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
+
+ // Execute system under test
+ final String mechanismName = server.getMechanismName();
+
+ // Verify result
+ assertEquals("SCRAM-SHA-1-PLUS", mechanismName, "PLUS mechanism should return 'SCRAM-SHA-1-PLUS' as its name");
+ }
+
+ /**
+ * Verifies that a completely malformed first client message is rejected.
+ */
+ @Test
+ void rejectsFirstMessage_invalidFormat()
+ {
+ // Setup test fixture
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
+
+ // Execute system under test & Verify result
+ assertThrows(SaslException.class,
+ () -> server.evaluateResponse("not-a-valid-scram-message".getBytes(StandardCharsets.UTF_8)),
+ "Malformed first client message should be rejected with SaslException");
+ }
+
+ /**
+ * Verifies that a first client message containing an empty username is rejected.
+ */
+ @Test
+ void rejectsFirstMessage_emptyUsername()
+ {
+ // Setup test fixture
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
+
+ // Execute system under test & Verify result
+ assertThrows(SaslException.class,
+ () -> server.evaluateResponse("n,,n=,r=clientnonce".getBytes(StandardCharsets.UTF_8)),
+ "First client message with empty username should be rejected");
+ }
+
+ /**
+ * Verifies that a first client message containing an empty client nonce is rejected.
+ */
+ @Test
+ void rejectsFirstMessage_emptyClientNonce()
+ {
+ // Setup test fixture
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
+
+ // Execute system under test & Verify result
+ assertThrows(SaslException.class,
+ () -> server.evaluateResponse("n,,n=user,r=".getBytes(StandardCharsets.UTF_8)),
+ "First client message with empty client nonce should be rejected");
+ }
+
+ /**
+ * Verifies that a 'p' GS2 channel-binding flag is rejected when using the non-PLUS mechanism.
+ */
+ @Test
+ void rejectsFirstMessage_channelBindingRequestedOnNonPlusMechanism()
+ {
+ // Setup test fixture
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
+
+ // Execute system under test & Verify result
+ assertThrows(SaslException.class,
+ () -> server.evaluateResponse("p=tls-unique,,n=user,r=clientnonce".getBytes(StandardCharsets.UTF_8)),
+ "Channel binding requested on non-PLUS mechanism should be rejected");
+ }
+
+ /**
+ * Verifies RFC 5802 §6: a 'y' GS2 flag MUST be rejected when the server advertises a -PLUS mechanism,
+ * because this is a signal that a downgrade attack may be in progress.
+ */
+ @Test
+ void rejectsFirstMessage_downgradeAttackDetected()
+ {
+ // Setup test fixture
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
+
+ // Execute system under test & Verify result
+ assertThrows(SaslException.class, () -> server.evaluateResponse("y,,n=user,r=clientnonce".getBytes(StandardCharsets.UTF_8)),
+ "Downgrade attack (y-flag) should be rejected when -PLUS is advertised");
+ }
+
+ /**
+ * Verifies that a completely malformed final client message is rejected.
+ */
+ @Test
+ void rejectsFinalMessage_invalidFormat() throws Exception
+ {
+ // Setup test fixture
+ setupSaltAndIterations();
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
+ server.evaluateResponse("n,,n=user,r=clientnonce".getBytes(StandardCharsets.UTF_8));
+
+ // Execute system under test & Verify result
+ assertThrows(SaslException.class, () -> server.evaluateResponse("not-a-valid-final-message".getBytes(StandardCharsets.UTF_8)),
+ "Malformed final client message should be rejected with SaslException");
+ }
+
+ /**
+ * Verifies that a final client message with an empty proof attribute is rejected.
+ */
+ @Test
+ void rejectsFinalMessage_emptyProof() throws Exception
+ {
+ // Setup test fixture
+ setupSaltAndIterations();
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
+ final String serverNonce = doFirstExchange(server);
+
+ // Execute system under test & Verify result
+ assertThrows(SaslException.class, () -> server.evaluateResponse(("c=biws,r=" + serverNonce + ",p=").getBytes(StandardCharsets.UTF_8)),
+ "Final client message with empty proof should be rejected");
+ }
+
+ /**
+ * Verifies that a final client message with an empty channel binding attribute is rejected.
+ */
+ @Test
+ void rejectsFinalMessage_emptyChannelBinding() throws Exception
+ {
+ // Setup test fixture
+ setupSaltAndIterations();
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
+ final String serverNonce = doFirstExchange(server);
+
+ // Execute system under test & Verify result
+ assertThrows(SaslException.class, () -> server.evaluateResponse(("c=,r=" + serverNonce + ",p=dGVzdA==").getBytes(StandardCharsets.UTF_8)),
+ "Final client message with empty channel binding should be rejected");
+ }
+
+ /**
+ * Verifies that a final client message containing an incorrect nonce is rejected.
+ */
+ @Test
+ void rejectsFinalMessage_incorrectNonce() throws Exception
+ {
+ // Setup test fixture
+ setupSaltAndIterations();
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
+ doFirstExchange(server);
+
+ // Execute system under test & Verify result
+ assertThrows(SaslException.class, () -> server.evaluateResponse("c=biws,r=completely-wrong-nonce,p=dGVzdA==".getBytes(StandardCharsets.UTF_8)),
+ "Final client message with incorrect nonce should be rejected");
+ }
+
+ /**
+ * Verifies that a final client message carrying an incorrect channel binding value is rejected
+ * for a non-PLUS exchange. For non-PLUS, c= must decode to exactly the GS2 header ("n,,"),
+ * whose base64 encoding is "biws".
+ */
+ @Test
+ void rejectsFinalMessage_incorrectChannelBindingValue_nonPlusMechanism() throws Exception
+ {
+ // Setup test fixture
+ setupSaltAndIterations();
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
+ final String serverNonce = doFirstExchange(server);
+ final String wrongBinding = Base64.getEncoder().encodeToString("p=tls-unique,,".getBytes(StandardCharsets.UTF_8));
+
+ // Execute system under test & Verify result
+ assertThrows(SaslException.class, () -> server.evaluateResponse(("c=" + wrongBinding + ",r=" + serverNonce + ",p=dGVzdA==").getBytes(StandardCharsets.UTF_8)),
+ "Final client message with incorrect channel binding value should be rejected");
+ }
+
+ /**
+ * Verifies that a proof whose decoded length differs from the HMAC-SHA-1 output length (20 bytes)
+ * is rejected with a clean SaslException rather than an ArrayIndexOutOfBoundsException.
+ */
+ @Test
+ void rejectsFinalMessage_proofWithWrongLength() throws Exception
+ {
+ // Setup test fixture
+ setupSaltAndIterationsAndKeys();
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
+ final String serverNonce = doFirstExchange(server);
+ final String shortProof = Base64.getEncoder().encodeToString(new byte[10]); // 10 bytes, not 20
+
+ // Execute system under test
+ final SaslException ex = assertThrows(SaslException.class, () -> server.evaluateResponse(("c=biws,r=" + serverNonce + ",p=" + shortProof).getBytes(StandardCharsets.UTF_8)),
+ "Final client message with proof of wrong length should be rejected");
+
+ // Verify result
+ assertTrue(ex.getMessage().contains("proof"), "Exception should mention the proof");
+ }
+
+ /**
+ * Verifies that a correctly structured final message carrying a wrong (but correctly sized) proof
+ * results in an authentication failure rather than a successful login.
+ */
+ @Test
+ void rejectsFinalMessage_incorrectProof() throws Exception
+ {
+ // Setup test fixture
+ setupSaltAndIterationsAndKeys();
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
+ final String serverNonce = doFirstExchange(server);
+ final String wrongProof = Base64.getEncoder().encodeToString(new byte[20]); // 20 zero bytes
+
+ // Execute system under test & Verify result
+ assertThrows(SaslException.class,() -> server.evaluateResponse(("c=biws,r=" + serverNonce + ",p=" + wrongProof).getBytes(StandardCharsets.UTF_8)),
+ "Final client message with incorrect proof should be rejected");
+ }
+
+ /**
+ * Verifies that isComplete() returns false before any exchange has taken place.
+ */
+ @Test
+ void isComplete_returnsFalse_initially()
+ {
+ // Setup test fixture
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
+
+ // Execute system under test
+ final boolean complete = server.isComplete();
+
+ // Verify result
+ assertFalse(complete, "isComplete() should return false before any exchange has taken place");
+ }
+
+ /**
+ * Verifies that isComplete() returns false after only the first exchange round.
+ */
+ @Test
+ void isComplete_returnsFalse_afterFirstExchangeOnly() throws Exception
+ {
+ // Setup test fixture
+ setupSaltAndIterations();
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
+ server.evaluateResponse("n,,n=user,r=clientnonce".getBytes(StandardCharsets.UTF_8));
+
+ // Execute system under test
+ final boolean complete = server.isComplete();
+
+ // Verify result
+ assertFalse(complete, "isComplete() should return false after only the first exchange");
+ }
+
+ /**
+ * Verifies that a non-empty response submitted after a completed exchange is rejected.
+ */
+ @Test
+ void rejectsNonEmptyResponse_afterExchangeComplete() throws Exception
+ {
+ // Setup test fixture
+ final ScramSha1SaslServer server = completeSuccessfulExchange();
+
+ // Execute system under test
+ assertTrue(server.isComplete(), "Server should be complete after successful exchange");
+
+ // Verify result
+ assertThrows(SaslException.class, () -> server.evaluateResponse("unexpected".getBytes(StandardCharsets.UTF_8)),
+ "Non-empty response after exchange complete should be rejected");
+ }
+
+ /**
+ * Verifies that an empty response submitted after a completed exchange is tolerated
+ * (some SASL frameworks send an empty final acknowledgement).
+ */
+ @Test
+ void acceptsEmptyResponse_afterExchangeComplete() throws Exception
+ {
+ // Setup test fixture
+ final ScramSha1SaslServer server = completeSuccessfulExchange();
+
+ // Execute system under test & Verify result
+ assertDoesNotThrow(() -> server.evaluateResponse(new byte[0]),
+ "Empty response after exchange complete should be tolerated");
+ }
+
+ /**
+ * Verifies that getAuthorizationID() throws before the exchange completes.
+ */
+ @Test
+ void getAuthorizationID_throwsIllegalStateException_beforeCompletion()
+ {
+ // Setup test fixture
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
+
+ // Execute system under test & Verify result
+ assertThrows(IllegalStateException.class, server::getAuthorizationID,
+ "getAuthorizationID() before completion should throw IllegalStateException");
+ }
+
+ /**
+ * Verifies that getAuthorizationID() returns the authenticated username after a successful exchange.
+ */
+ @Test
+ void getAuthorizationID_returnsUsername_afterCompletion() throws Exception
+ {
+ // Setup test fixture
+ final ScramSha1SaslServer server = completeSuccessfulExchange();
+
+ // Execute system under test
+ final String authzId = server.getAuthorizationID();
+
+ // Verify result
+ assertEquals("user", authzId, "getAuthorizationID() should return the authenticated username after completion");
+ }
+
+ /**
+ * Verifies that getNegotiatedProperty() throws before the exchange completes.
+ */
+ @Test
+ void getNegotiatedProperty_throwsIllegalStateException_beforeCompletion()
+ {
+ // Setup test fixture
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
+
+ // Execute system under test & Verify result
+ assertThrows(IllegalStateException.class, () -> server.getNegotiatedProperty(Sasl.QOP),
+ "getNegotiatedProperty() before completion should throw IllegalStateException");
+ }
+
+ /**
+ * Verifies that getNegotiatedProperty() reports "auth" for QOP after a successful exchange,
+ * as SCRAM-SHA-1 provides authentication only (no integrity or confidentiality layer).
+ */
+ @Test
+ void getNegotiatedProperty_returnsAuth_forQOP_afterCompletion() throws Exception
+ {
+ // Setup test fixture
+ final ScramSha1SaslServer server = completeSuccessfulExchange();
+
+ // Execute system under test
+ final Object qop = server.getNegotiatedProperty(Sasl.QOP);
+
+ // Verify result
+ assertEquals("auth", qop, "getNegotiatedProperty(Sasl.QOP) should return 'auth' after completion");
+ }
+
+ /**
+ * Verifies that getNegotiatedProperty() returns null for unknown properties after completion.
+ */
+ @Test
+ void getNegotiatedProperty_returnsNull_forUnknownProperty_afterCompletion() throws Exception
+ {
+ // Setup test fixture
+ final ScramSha1SaslServer server = completeSuccessfulExchange();
+
+ // Execute system under test
+ final Object unknown = server.getNegotiatedProperty("unknown.property");
+
+ // Verify result
+ assertNull(unknown, "getNegotiatedProperty() should return null for unknown properties after completion");
+ }
+
+ /**
+ * Verifies that unwrap() always throws, as SCRAM-SHA-1 has no security layer.
+ */
+ @Test
+ void unwrap_throwsIllegalStateException_always()
+ {
+ // Setup test fixture
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
+
+ // Execute system under test & Verify result
+ assertThrows(IllegalStateException.class, () -> server.unwrap(new byte[]{1, 2, 3}, 0, 3),
+ "unwrap() should always throw IllegalStateException as SCRAM-SHA-1 has no security layer");
+ }
+
+ /**
+ * Verifies that wrap() always throws, as SCRAM-SHA-1 has no security layer.
+ */
+ @Test
+ void wrap_throwsIllegalStateException_always()
+ {
+ // Setup test fixture
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
+
+ // Execute system under test & Verify result
+ assertThrows(IllegalStateException.class, () -> server.wrap(new byte[]{1, 2, 3}, 0, 3),
+ "wrap() should always throw IllegalStateException as SCRAM-SHA-1 has no security layer");
+ }
+
+ /**
+ * Verifies that dispose() resets the server to its initial state, making isComplete() return false
+ * and preventing getAuthorizationID() from returning stale data.
+ */
+ @Test
+ void dispose_resetsStateAndClearsSensitiveFields() throws Exception
+ {
+ // Setup test fixture
+ final ScramSha1SaslServer server = completeSuccessfulExchange();
+ assertTrue(server.isComplete(), "Server should be complete after successful exchange");
+
+ // Execute system under test
+ server.dispose();
+
+ // Verify result
+ assertFalse(server.isComplete(), "Server should not be complete after dispose()");
+ assertThrows(IllegalStateException.class, server::getAuthorizationID,
+ "getAuthorizationID() should throw after dispose()");
+ }
+
+ // -------------------------------------------------------------------------
+ // Private helpers
+ // -------------------------------------------------------------------------
+
+ private void setupSaltAndIterations()
+ {
+ authFactory.when(() -> AuthFactory.getSalt(any())).thenReturn("QSXCR+Q6sek8bf92");
+ authFactory.when(() -> AuthFactory.getIterations(any())).thenReturn(4096);
+ }
+
+ private void setupSaltAndIterationsAndKeys()
+ {
+ setupSaltAndIterations();
+ authFactory.when(() -> AuthFactory.getStoredKey(any()))
+ .thenReturn(DatatypeConverter.printBase64Binary(StringUtils.decodeHex("e9d94660c39d65c38fbad91c358f14da0eef2bd6")));
+ authFactory.when(() -> AuthFactory.getServerKey(any()))
+ .thenReturn(DatatypeConverter.printBase64Binary(StringUtils.decodeHex("0fe09258b3ac852ba502cc62ba903eaacdbf7d31")));
+ }
+
+ /**
+ * Performs the first exchange round and returns the composite server nonce.
+ */
+ private String doFirstExchange(final ScramSha1SaslServer server) throws SaslException
+ {
+ final String firstServerResponse = new String(
+ server.evaluateResponse("n,,n=user,r=clientnonce".getBytes(StandardCharsets.UTF_8)),
+ StandardCharsets.UTF_8);
+ final Matcher m = Pattern.compile("r=([^,]*),.+").matcher(firstServerResponse);
+ assertTrue(m.matches(), "First server response did not match expected pattern");
+ return m.group(1);
+ }
+
+ /**
+ * Drives a complete successful SCRAM-SHA-1 exchange and returns the completed server instance.
+ */
+ private ScramSha1SaslServer completeSuccessfulExchange() throws Exception
+ {
+ final String hardCodedClientNonce = "fyko+d2lbbFgONRv9qkxdawL";
+ final String hardCodedPassword = "pencil";
+
+ setupSaltAndIterationsAndKeys();
+ authFactory.when(() -> AuthFactory.getPassword(any())).thenReturn(hardCodedPassword);
+
+ final ScramSha1SaslServer server = new ScramSha1SaslServer(false, new HashMap<>(), new ChannelBindingProviderManager(), Set.of("SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"));
+ final byte[] initialMessage = ("n,,n=user,r=" + hardCodedClientNonce).getBytes(StandardCharsets.UTF_8);
+ final String firstServerResponse = new String(server.evaluateResponse(initialMessage), StandardCharsets.UTF_8);
+
+ final Matcher m = Pattern.compile("r=([^,]*),s=([^,]*),i=(.*)$").matcher(firstServerResponse);
+ assertTrue(m.matches());
+ final String serverNonce = m.group(1);
+ final byte[] salt = DatatypeConverter.parseBase64Binary(m.group(2));
+ final int iterations = Integer.parseInt(m.group(3));
+
+ final SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
+ final KeySpec spec = new PBEKeySpec(hardCodedPassword.toCharArray(), salt, iterations, 160);
+ final byte[] saltedPassword = factory.generateSecret(spec).getEncoded();
+
+ final byte[] clientKey = new HmacUtils(HmacAlgorithms.HMAC_SHA_1, saltedPassword).hmac("Client Key");
+ final byte[] storedKey = StringUtils.decodeHex(StringUtils.hash(clientKey, "SHA-1"));
+ final String clientFinalBare = "c=biws,r=" + serverNonce;
+ final String authMessage = "n=user,r=" + hardCodedClientNonce + "," + firstServerResponse + "," + clientFinalBare;
+ final byte[] clientSignature = new HmacUtils(HmacAlgorithms.HMAC_SHA_1, storedKey).hmac(authMessage);
+
+ final byte[] clientProof = new byte[clientKey.length];
+ for (int i = 0; i < clientKey.length; i++) {
+ clientProof[i] = (byte) (clientKey[i] ^ clientSignature[i]);
+ }
+
+ final String clientFinalMessage = clientFinalBare + ",p=" + Base64.getEncoder().encodeToString(clientProof);
+ server.evaluateResponse(clientFinalMessage.getBytes(StandardCharsets.UTF_8));
+ return server;
+ }
}
diff --git a/xmppserver/src/test/java/org/jivesoftware/util/channelbinding/ChannelBindingProviderManagerTest.java b/xmppserver/src/test/java/org/jivesoftware/util/channelbinding/ChannelBindingProviderManagerTest.java
new file mode 100644
index 0000000000..97c8e80118
--- /dev/null
+++ b/xmppserver/src/test/java/org/jivesoftware/util/channelbinding/ChannelBindingProviderManagerTest.java
@@ -0,0 +1,336 @@
+/*
+ * Copyright (C) 2026 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.util.channelbinding;
+
+import org.dom4j.DocumentHelper;
+import org.dom4j.Element;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import javax.net.ssl.SSLEngine;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * Unit tests for {@link ChannelBindingProviderManager}.
+ */
+class ChannelBindingProviderManagerTest
+{
+ private ChannelBindingProviderManager manager;
+
+ @BeforeEach
+ void setUp()
+ {
+ manager = new ChannelBindingProviderManager();
+ }
+
+ /**
+ * Should return the binding from the first provider that supplies it.
+ */
+ @Test
+ void returnsBindingFromRegisteredProvider()
+ {
+ // Setup test fixture
+ final ChannelBindingProvider provider = mock(ChannelBindingProvider.class);
+ final SSLEngine engine = mock(SSLEngine.class);
+ final byte[] expected = new byte[] {1, 2, 3};
+ when(provider.getType()).thenReturn("tls-exporter");
+ when(provider.getChannelBinding(engine)).thenReturn(Optional.of(expected));
+ manager.addProvider(provider);
+
+ // Execute system under test
+ final Optional result = manager.getChannelBinding("tls-exporter", engine);
+
+ // Verify result
+ assertTrue(result.isPresent(), "Expected binding to be present when provider returns a value");
+ assertArrayEquals(expected, result.get(), "Returned binding does not match expected value");
+ }
+
+ /**
+ * Should return empty when there are no providers.
+ */
+ @Test
+ void returnsEmptyWhenNoProviders()
+ {
+ // Setup test fixture
+ final SSLEngine engine = mock(SSLEngine.class);
+
+ // Execute system under test
+ final Optional result = manager.getChannelBinding("tls-exporter", engine);
+
+ // Verify result
+ assertFalse(result.isPresent(), "Expected empty result when no providers are registered");
+ }
+
+ /**
+ * Should return empty when the provider returns empty.
+ */
+ @Test
+ void returnsEmptyWhenProviderReturnsEmpty()
+ {
+ // Setup test fixture
+ final ChannelBindingProvider provider = mock(ChannelBindingProvider.class);
+ final SSLEngine engine = mock(SSLEngine.class);
+ when(provider.getType()).thenReturn("tls-exporter");
+ when(provider.getChannelBinding(engine)).thenReturn(Optional.empty());
+ manager.addProvider(provider);
+
+ // Execute system under test
+ final Optional result = manager.getChannelBinding("tls-exporter", engine);
+
+ // Verify result
+ assertFalse(result.isPresent(), "Expected empty result when provider returns empty");
+ }
+
+ /**
+ * Should return empty when the provider throws an exception.
+ */
+ @Test
+ void returnsEmptyWhenProviderThrows()
+ {
+ // Setup test fixture
+ final ChannelBindingProvider provider = mock(ChannelBindingProvider.class);
+ final SSLEngine engine = mock(SSLEngine.class);
+ when(provider.getType()).thenReturn("tls-exporter");
+ when(provider.getChannelBinding(engine)).thenThrow(new RuntimeException("fail"));
+ manager.addProvider(provider);
+
+ // Execute system under test
+ final Optional result = manager.getChannelBinding("tls-exporter", engine);
+
+ // Verify result
+ assertFalse(result.isPresent(), "Expected empty result when provider throws");
+ }
+
+ /**
+ * Should remove a provider and no longer return its binding.
+ */
+ @Test
+ void removeProviderRemovesIt()
+ {
+ // Setup test fixture
+ final ChannelBindingProvider provider = mock(ChannelBindingProvider.class);
+ final SSLEngine engine = mock(SSLEngine.class);
+ when(provider.getType()).thenReturn("tls-exporter");
+ when(provider.getChannelBinding(engine)).thenReturn(Optional.of(new byte[] {1}));
+ manager.addProvider(provider);
+ assertTrue(manager.removeProvider(provider), "Provider should be removed successfully");
+
+ // Execute system under test
+ final Optional result = manager.getChannelBinding("tls-exporter", engine);
+
+ // Verify result
+ assertFalse(result.isPresent(), "Expected empty result after provider is removed");
+ }
+
+ /**
+ * Should return false when removing a provider that was never registered.
+ */
+ @Test
+ void removeProviderReturnsFalseIfNotPresent()
+ {
+ // Setup test fixture
+ final ChannelBindingProvider provider = mock(ChannelBindingProvider.class);
+ when(provider.getType()).thenReturn("tls-exporter");
+
+ // Execute system under test & verify result
+ assertFalse(manager.removeProvider(provider), "Should return false when provider was not registered");
+ }
+
+ /**
+ * Should use the provider added to the head before those added to the tail.
+ */
+ @Test
+ void addProviderToHeadOverridesOrder()
+ {
+ // Setup test fixture
+ final ChannelBindingProvider tail = mock(ChannelBindingProvider.class);
+ final ChannelBindingProvider head = mock(ChannelBindingProvider.class);
+ final SSLEngine engine = mock(SSLEngine.class);
+ when(tail.getType()).thenReturn("tls-exporter");
+ when(head.getType()).thenReturn("tls-exporter");
+ when(tail.getChannelBinding(engine)).thenReturn(Optional.empty());
+ final byte[] expected = new byte[] {9, 9, 9};
+ when(head.getChannelBinding(engine)).thenReturn(Optional.of(expected));
+ manager.addProvider(tail);
+ manager.addProviderToHead(head);
+
+ // Execute system under test
+ final Optional result = manager.getChannelBinding("tls-exporter", engine);
+
+ // Verify result
+ assertTrue(result.isPresent(), "Expected binding to be present from head provider");
+ assertArrayEquals(expected, result.get(), "Returned binding does not match expected value from head provider");
+ }
+
+ /**
+ * Should try multiple providers for the same prefix in order and return the first non-empty result.
+ */
+ @Test
+ void multipleProvidersForSamePrefixAreTriedInOrder_emptyFirst()
+ {
+ // Setup test fixture
+ final ChannelBindingProvider first = mock(ChannelBindingProvider.class);
+ final ChannelBindingProvider second = mock(ChannelBindingProvider.class);
+ final SSLEngine engine = mock(SSLEngine.class);
+ when(first.getType()).thenReturn("tls-exporter");
+ when(second.getType()).thenReturn("tls-exporter");
+ when(first.getChannelBinding(engine)).thenReturn(Optional.empty());
+ final byte[] expected = new byte[] {7, 7, 7};
+ when(second.getChannelBinding(engine)).thenReturn(Optional.of(expected));
+ manager.addProvider(first);
+ manager.addProvider(second);
+
+ // Execute system under test
+ final Optional result = manager.getChannelBinding("tls-exporter", engine);
+
+ // Verify result
+ assertTrue(result.isPresent(), "Expected binding to be present from second provider");
+ assertArrayEquals(expected, result.get(), "Returned binding does not match expected value from second provider");
+ }
+
+ /**
+ * Should try multiple providers for the same prefix in order and return the first non-empty result, skipping exceptions.
+ */
+ @Test
+ void multipleProvidersForSamePrefixAreTriedInOrder_exceptionFirst()
+ {
+ // Setup test fixture
+ final ChannelBindingProvider first = mock(ChannelBindingProvider.class);
+ final ChannelBindingProvider second = mock(ChannelBindingProvider.class);
+ final SSLEngine engine = mock(SSLEngine.class);
+ when(first.getType()).thenReturn("tls-exporter");
+ when(second.getType()).thenReturn("tls-exporter");
+ when(first.getChannelBinding(engine)).thenThrow(new RuntimeException("fail"));
+ final byte[] expected = new byte[] {8, 8, 8};
+ when(second.getChannelBinding(engine)).thenReturn(Optional.of(expected));
+ manager.addProvider(first);
+ manager.addProvider(second);
+
+ // Execute system under test
+ final Optional result = manager.getChannelBinding("tls-exporter", engine);
+
+ // Verify result
+ assertTrue(result.isPresent(), "Expected binding to be present from second provider after exception in first");
+ assertArrayEquals(expected, result.get(), "Returned binding does not match expected value from second provider");
+ }
+
+ /**
+ * Should reflect registered providers in supportsChannelBinding.
+ */
+ @Test
+ void supportsChannelBindingReflectsRegisteredProviders()
+ {
+ // Setup test fixture
+ final ChannelBindingProvider provider = mock(ChannelBindingProvider.class);
+ when(provider.getType()).thenReturn("tls-exporter");
+ assertFalse(manager.supportsChannelBinding("tls-exporter"), "Should not support prefix before registration");
+ manager.addProvider(provider);
+ assertTrue(manager.supportsChannelBinding("tls-exporter"), "Should support prefix after registration");
+ manager.removeProvider(provider);
+ assertFalse(manager.supportsChannelBinding("tls-exporter"), "Should not support prefix after removal");
+ }
+
+ /**
+ * Should reflect registered providers in getSupportedChannelBindingTypes.
+ */
+ @Test
+ void getSupportedChannelBindingTypesReflectsRegisteredProviders()
+ {
+ // Setup test fixture
+ final ChannelBindingProvider exporter = mock(ChannelBindingProvider.class);
+ final ChannelBindingProvider serverEndPoint = mock(ChannelBindingProvider.class);
+ when(exporter.getType()).thenReturn("tls-exporter");
+ when(serverEndPoint.getType()).thenReturn("tls-server-end-point");
+ assertTrue(manager.getSupportedChannelBindingTypes().isEmpty(), "Should be empty before registration");
+ manager.addProvider(exporter);
+ assertTrue(manager.getSupportedChannelBindingTypes().contains("tls-exporter"), "Should contain tls-exporter after registration");
+ manager.addProvider(serverEndPoint);
+ assertTrue(manager.getSupportedChannelBindingTypes().contains("tls-server-end-point"), "Should contain tls-server-end-point after registration");
+ manager.removeProvider(exporter);
+ assertFalse(manager.getSupportedChannelBindingTypes().contains("tls-exporter"), "Should not contain tls-exporter after removal");
+ assertTrue(manager.getSupportedChannelBindingTypes().contains("tls-server-end-point"), "Should still contain tls-server-end-point");
+ }
+
+ /**
+ * Test when no mechanism ends with -PLUS, the result of #getSASLChannelBindingTypeCapabilityElement should be empty.
+ */
+ @Test
+ void testNoPlusMechanismReturnsEmpty()
+ {
+ // Setup test fixture
+ final ChannelBindingProviderManager mgr = new ChannelBindingProviderManager();
+ final Element mechanisms = DocumentHelper.createElement("mechanisms");
+ mechanisms.addElement("mechanism").setText("PLAIN");
+
+ // Execute system under test
+ final Optional result = mgr.getSASLChannelBindingTypeCapabilityElement(mechanisms);
+
+ // Verify result
+ assertTrue(result.isEmpty(), "Should return empty when no -PLUS mechanism is present");
+ }
+
+ /**
+ * Test when a -PLUS mechanism is present but no supported channel binding types, the result of
+ * #getSASLChannelBindingTypeCapabilityElement should be empty.
+ */
+ @Test
+ void testPlusMechanismNoChannelBindingTypesReturnsEmpty()
+ {
+ // Setup test fixture
+ final ChannelBindingProviderManager mgr = new ChannelBindingProviderManager();
+ final Element mechanisms = DocumentHelper.createElement("mechanisms");
+ mechanisms.addElement("mechanism").setText("SCRAM-SHA-1-PLUS");
+
+ // Execute system under test
+ Optional result = mgr.getSASLChannelBindingTypeCapabilityElement(mechanisms);
+
+ // Verify result
+ assertTrue(result.isEmpty(), "Should return empty when no channel binding types are supported");
+ }
+
+ /**
+ * Test when a -PLUS mechanism and supported channel binding types are present, the result of
+ * #getSASLChannelBindingTypeCapabilityElement should contain the expected element.
+ */
+ @Test
+ void testPlusMechanismWithChannelBindingTypesReturnsElement()
+ {
+ // Setup test fixture
+ final ChannelBindingProviderManager mgr = new ChannelBindingProviderManager();
+ final Element mechanisms = DocumentHelper.createElement("mechanisms");
+ mechanisms.addElement("mechanism").setText("SCRAM-SHA-1-PLUS");
+ // Register mock providers for two channel binding types
+ final ChannelBindingProvider cbp1 = mock(ChannelBindingProvider.class);
+ when(cbp1.getType()).thenReturn("tls-server-end-point");
+ final ChannelBindingProvider cbp2 = mock(ChannelBindingProvider.class);
+ when(cbp2.getType()).thenReturn("tls-exporter");
+ mgr.addProvider(cbp1);
+ mgr.addProvider(cbp2);
+
+ // Execute system under test
+ final Optional result = mgr.getSASLChannelBindingTypeCapabilityElement(mechanisms);
+
+ // Verify result
+ assertTrue(result.isPresent(), "Should return element when -PLUS mechanism and channel binding types are present");
+ final Element cbElement = result.get();
+ assertEquals("sasl-channel-binding", cbElement.getName(), "Element name should be sasl-channel-binding");
+ assertEquals("urn:xmpp:sasl-cb:0", cbElement.getNamespaceURI(), "Namespace should be urn:xmpp:sasl-cb:0");
+ assertEquals(2, cbElement.elements("channel-binding").size(), "Should have two channel-binding elements");
+ }
+}
diff --git a/xmppserver/src/test/java/org/jivesoftware/util/channelbinding/TlsServerEndPointChannelBindingProviderTest.java b/xmppserver/src/test/java/org/jivesoftware/util/channelbinding/TlsServerEndPointChannelBindingProviderTest.java
new file mode 100644
index 0000000000..26092c9d35
--- /dev/null
+++ b/xmppserver/src/test/java/org/jivesoftware/util/channelbinding/TlsServerEndPointChannelBindingProviderTest.java
@@ -0,0 +1,481 @@
+/*
+ * Copyright (C) 2026 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.util.channelbinding;
+
+import org.junit.jupiter.api.Test;
+
+import javax.net.ssl.SSLEngine;
+import javax.net.ssl.SSLPeerUnverifiedException;
+import javax.net.ssl.SSLSession;
+import java.security.MessageDigest;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+import java.util.Optional;
+import java.security.spec.PSSParameterSpec;
+import java.security.AlgorithmParameters;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * Unit tests for TLS Server End Point channel binding extraction.
+ *
+ * These tests verify the correct extraction, normalization, and resolution of channel binding data
+ * for the server end point type as defined in RFC 5929.
+ */
+public class TlsServerEndPointChannelBindingProviderTest
+{
+ // The provider under test
+ private final TlsServerEndPointChannelBindingProvider provider = new TlsServerEndPointChannelBindingProvider();
+
+ // ----------- Server-side (local certificates) tests -----------
+
+ /**
+ * Should return empty when no local certificates are present (server-side).
+ */
+ @Test
+ void testServerSide_noLocalCertificates_returnsEmpty()
+ {
+ // Setup test fixture
+ final SSLSession session = mock(SSLSession.class);
+ when(session.getLocalCertificates()).thenReturn(null);
+ final SSLEngine engine = mock(SSLEngine.class);
+ when(engine.getSession()).thenReturn(session);
+ when(engine.getUseClientMode()).thenReturn(false);
+
+ // Execute system under test
+ final Optional result = provider.getChannelBinding(engine);
+
+ // Verify results
+ assertFalse(result.isPresent(), "Should be empty when no local certificates are present (server-side)");
+ }
+
+ /**
+ * Should return the expected digest when a valid X509 local certificate is present (server-side).
+ */
+ @Test
+ void testServerSide_withLocalX509Certificate_returnsDigest() throws Exception
+ {
+ // Setup test fixture
+ final SSLSession session = mock(SSLSession.class);
+ final X509Certificate cert = mock(X509Certificate.class);
+ when(cert.getSigAlgName()).thenReturn("SHA256withRSA");
+ when(cert.getEncoded()).thenReturn(new byte[] {10, 20, 30});
+ when(session.getLocalCertificates()).thenReturn(new Certificate[] { cert });
+ final SSLEngine engine = mock(SSLEngine.class);
+ when(engine.getSession()).thenReturn(session);
+ when(engine.getUseClientMode()).thenReturn(false);
+ final byte[] expected = MessageDigest.getInstance("SHA-256").digest(new byte[] {10, 20, 30});
+
+ // Execute system under test
+ final Optional result = provider.getChannelBinding(engine);
+
+ // Verify results
+ assertTrue(result.isPresent(), "Should be present when X509 certificate is present (server-side)");
+ assertArrayEquals(expected, result.get(), "Digest should match expected SHA-256 digest (server-side)");
+ }
+
+ /**
+ * Should return empty when the local certificate is not X509 (server-side).
+ */
+ @Test
+ void testServerSide_localCertificateNotX509_returnsEmpty()
+ {
+ // Setup test fixture
+ final SSLSession session = mock(SSLSession.class);
+ final Certificate cert = mock(Certificate.class); // Not X509
+ when(session.getLocalCertificates()).thenReturn(new Certificate[] { cert });
+ final SSLEngine engine = mock(SSLEngine.class);
+ when(engine.getSession()).thenReturn(session);
+ when(engine.getUseClientMode()).thenReturn(false);
+
+ // Execute system under test
+ final Optional result = provider.getChannelBinding(engine);
+
+ // Verify results
+ assertFalse(result.isPresent(), "Should be empty when local certificate is not X509 (server-side)");
+ }
+
+ // ----------- Client-side (peer certificates) tests -----------
+
+ /**
+ * Should return empty when no peer certificates are present (client-side).
+ */
+ @Test
+ void testClientSide_noPeerCertificates_returnsEmpty() throws SSLPeerUnverifiedException
+ {
+ // Setup test fixture
+ final SSLSession session = mock(SSLSession.class);
+ when(session.getPeerCertificates()).thenReturn(null);
+ final SSLEngine engine = mock(SSLEngine.class);
+ when(engine.getSession()).thenReturn(session);
+ when(engine.getUseClientMode()).thenReturn(true);
+
+ // Execute system under test
+ final Optional result = provider.getChannelBinding(engine);
+
+ // Verify results
+ assertFalse(result.isPresent(), "Should be empty when no peer certificates are present (client-side)");
+ }
+
+ /**
+ * Should return the expected digest when a valid X509 peer certificate is present (client-side).
+ */
+ @Test
+ void testClientSide_withPeerX509Certificate_returnsDigest() throws Exception
+ {
+ // Setup test fixture
+ final SSLSession session = mock(SSLSession.class);
+ final X509Certificate cert = mock(X509Certificate.class);
+ when(cert.getSigAlgName()).thenReturn("SHA256withRSA");
+ when(cert.getEncoded()).thenReturn(new byte[] {40, 50, 60});
+ when(session.getPeerCertificates()).thenReturn(new Certificate[] { cert });
+ final SSLEngine engine = mock(SSLEngine.class);
+ when(engine.getSession()).thenReturn(session);
+ when(engine.getUseClientMode()).thenReturn(true);
+ final byte[] expected = MessageDigest.getInstance("SHA-256").digest(new byte[] {40, 50, 60});
+
+ // Execute system under test
+ final Optional result = provider.getChannelBinding(engine);
+
+ // Verify results
+ assertTrue(result.isPresent(), "Should be present when X509 peer certificate is present (client-side)");
+ assertArrayEquals(expected, result.get(), "Digest should match expected SHA-256 digest (client-side)");
+ }
+
+ /**
+ * Should return empty when the peer certificate is not X509 (client-side).
+ */
+ @Test
+ void testClientSide_peerCertificateNotX509_returnsEmpty() throws SSLPeerUnverifiedException
+ {
+ // Setup test fixture
+ final SSLSession session = mock(SSLSession.class);
+ final Certificate cert = mock(Certificate.class); // Not X509
+ when(session.getPeerCertificates()).thenReturn(new Certificate[] { cert });
+ final SSLEngine engine = mock(SSLEngine.class);
+ when(engine.getSession()).thenReturn(session);
+ when(engine.getUseClientMode()).thenReturn(true);
+
+ // Execute system under test
+ final Optional result = provider.getChannelBinding(engine);
+
+ // Verify results
+ assertFalse(result.isPresent(), "Should be empty when peer certificate is not X509 (client-side)");
+ }
+
+ /**
+ * Should return null if extractPssHashAlgorithm returns null.
+ */
+ @Test
+ void testResolveServerEndPointHashAlgorithmPSS_null()
+ {
+ // Setup test fixture
+ final X509Certificate cert = mock(X509Certificate.class);
+
+ // Execute system under test
+ final String result = provider.resolveServerEndPointHashAlgorithmPSS(cert);
+
+ // Verify results
+ assertNull(result, "Should return null when extractPssHashAlgorithm returns null (e.g., null sigAlgParams)");
+ }
+
+ /**
+ * Verifies that extractPssHashAlgorithm returns null if sigAlgParams is null or if an exception is thrown.
+ */
+ @Test
+ void testExtractPssHashAlgorithm_nullOrException()
+ {
+ // Setup test fixture
+ final X509Certificate certNull = mock(X509Certificate.class);
+ when(certNull.getSigAlgParams()).thenReturn(null);
+ final X509Certificate certException = mock(X509Certificate.class);
+ when(certException.getSigAlgParams()).thenThrow(new RuntimeException("fail"));
+
+ // Execute system under test & Verify results
+ assertNull(provider.extractPssHashAlgorithm(certNull), "Should return null when sigAlgParams is null");
+ assertNull(provider.extractPssHashAlgorithm(certException), "Should return null when sigAlgParams throws an exception");
+ }
+
+ /**
+ * Verifies that extractPssHashAlgorithm returns the correct hash algorithm when valid PSS parameters are present.
+ */
+ @Test
+ void testExtractPssHashAlgorithm_happyFlow() throws Exception
+ {
+ // Setup test fixture
+ final X509Certificate cert = mock(X509Certificate.class);
+ final PSSParameterSpec pssSpec = new PSSParameterSpec("SHA-512", "MGF1", PSSParameterSpec.DEFAULT.getMGFParameters(), 32, 1);
+ final AlgorithmParameters ap = AlgorithmParameters.getInstance("RSASSA-PSS");
+ ap.init(pssSpec);
+ final byte[] params = ap.getEncoded();
+ when(cert.getSigAlgParams()).thenReturn(params);
+
+ // Execute system under test
+ final String result = provider.extractPssHashAlgorithm(cert);
+
+ // Verify results
+ assertEquals("SHA-512", result, "Should return the correct digest algorithm from PSS parameters");
+ }
+
+ /**
+ * Verifies normalization: "SHA256" becomes "SHA-256".
+ */
+ @Test
+ void testNormalizeServerEndPointHashAlgorithmName_SHA256()
+ {
+ // Execute & Verify results
+ assertEquals("SHA-256", provider.normalizeServerEndPointHashAlgorithmName("SHA256"), "Should normalize 'SHA256' to 'SHA-256'");
+ }
+
+ /**
+ * Verifies normalization: "SHA-256" becomes "SHA-256".
+ */
+ @Test
+ void testNormalizeServerEndPointHashAlgorithmName_SHA_256()
+ {
+ // Execute & Verify results
+ assertEquals("SHA-256", provider.normalizeServerEndPointHashAlgorithmName("SHA-256"), "Should normalize 'SHA-256' to 'SHA-256'");
+ }
+
+ /**
+ * Verifies normalization: "SHA512" becomes "SHA-512".
+ */
+ @Test
+ void testNormalizeServerEndPointHashAlgorithmName_SHA512()
+ {
+ // Execute & Verify results
+ assertEquals("SHA-512", provider.normalizeServerEndPointHashAlgorithmName("SHA512"), "Should normalize 'SHA512' to 'SHA-512'");
+ }
+
+ /**
+ * Verifies normalization: "SHA3-256" becomes "SHA3-256".
+ */
+ @Test
+ void testNormalizeServerEndPointHashAlgorithmName_SHA3_256()
+ {
+ // Execute & Verify results
+ assertEquals("SHA3-256", provider.normalizeServerEndPointHashAlgorithmName("SHA3-256"), "Should normalize 'SHA3-256' to 'SHA3-256'");
+ }
+
+ /**
+ * Verifies normalization: "MD5" becomes "MD5".
+ */
+ @Test
+ void testNormalizeServerEndPointHashAlgorithmName_MD5()
+ {
+ // Execute & Verify results
+ assertEquals("MD5", provider.normalizeServerEndPointHashAlgorithmName("MD5"), "Should normalize 'MD5' to 'MD5'");
+ }
+
+ /**
+ * Verifies normalization: "sha1" becomes "SHA-1".
+ */
+ @Test
+ void testNormalizeServerEndPointHashAlgorithmName_sha1()
+ {
+ // Execute & Verify results
+ assertEquals("SHA-1", provider.normalizeServerEndPointHashAlgorithmName("sha1"), "Should normalize 'sha1' to 'SHA-1'");
+ }
+
+ /**
+ * Verifies normalization: "SHA384" becomes "SHA-384".
+ */
+ @Test
+ void testNormalizeServerEndPointHashAlgorithmName_SHA384()
+ {
+ // Execute & Verify results
+ assertEquals("SHA-384", provider.normalizeServerEndPointHashAlgorithmName("SHA384"), "Should normalize 'SHA384' to 'SHA-384'");
+ }
+
+ /**
+ * Verifies normalization: "SHA3-512" becomes "SHA3-512".
+ */
+ @Test
+ void testNormalizeServerEndPointHashAlgorithmName_SHA3_512()
+ {
+ // Execute & Verify results
+ assertEquals("SHA3-512", provider.normalizeServerEndPointHashAlgorithmName("SHA3-512"), "Should normalize 'SHA3-512' to 'SHA3-512'");
+ }
+
+ // ----------- Hash algorithm resolution tests -----------
+
+ /**
+ * Verifies resolution: "SHA256withRSA" becomes "SHA-256".
+ */
+ @Test
+ void testResolveServerEndPointHashAlgorithm_SHA256withRSA()
+ {
+ assertEquals("SHA-256", provider.resolveServerEndPointHashAlgorithm("SHA256withRSA"), "Resolution of 'SHA256withRSA' did not yield 'SHA-256'.");
+ }
+
+ /**
+ * Verifies resolution: "SHA512withECDSA" becomes "SHA-512".
+ */
+ @Test
+ void testResolveServerEndPointHashAlgorithm_SHA512withECDSA()
+ {
+ assertEquals("SHA-512", provider.resolveServerEndPointHashAlgorithm("SHA512withECDSA"), "Resolution of 'SHA512withECDSA' did not yield 'SHA-512'.");
+ }
+
+ /**
+ * Verifies resolution: "SHA1withRSA" becomes fallback "SHA-256".
+ */
+ @Test
+ void testResolveServerEndPointHashAlgorithm_SHA1withRSA_fallback()
+ {
+ assertEquals("SHA-256", provider.resolveServerEndPointHashAlgorithm("SHA1withRSA"), "Resolution of weak hash 'SHA1withRSA' did not yield fallback 'SHA-256'.");
+ }
+
+ /**
+ * Verifies resolution: "MD5withRSA" becomes fallback "SHA-256".
+ */
+ @Test
+ void testResolveServerEndPointHashAlgorithm_MD5withRSA_fallback()
+ {
+ assertEquals("SHA-256", provider.resolveServerEndPointHashAlgorithm("MD5withRSA"), "Resolution of weak hash 'MD5withRSA' did not yield fallback 'SHA-256'.");
+ }
+
+ /**
+ * Verifies resolution: "SHA3-256withECDSA" becomes "SHA3-256".
+ */
+ @Test
+ void testResolveServerEndPointHashAlgorithm_SHA3_256withECDSA()
+ {
+ assertEquals("SHA3-256", provider.resolveServerEndPointHashAlgorithm("SHA3-256withECDSA"), "Resolution of 'SHA3-256withECDSA' did not yield 'SHA3-256'.");
+ }
+
+ /**
+ * Verifies resolution: "RSASSA-PSS" becomes null (no 'with').
+ */
+ @Test
+ void testResolveServerEndPointHashAlgorithm_RSASSA_PSS_null()
+ {
+ assertNull(provider.resolveServerEndPointHashAlgorithm("RSASSA-PSS"), "Resolution of 'RSASSA-PSS' (no 'with') did not yield null as expected.");
+ }
+
+ /**
+ * Verifies resolution: null becomes null.
+ */
+ @Test
+ void testResolveServerEndPointHashAlgorithm_null_null()
+ {
+ assertNull(provider.resolveServerEndPointHashAlgorithm(null), "Resolution of null algorithm name did not yield null as expected.");
+ }
+
+ /**
+ * Verifies resolution: "SHA256" becomes null (no 'with').
+ */
+ @Test
+ void testResolveServerEndPointHashAlgorithm_SHA256_null()
+ {
+ assertNull(provider.resolveServerEndPointHashAlgorithm("SHA256"), "Resolution of 'SHA256' (no 'with') did not yield null as expected.");
+ }
+
+ /**
+ * Verifies resolution: "SHA256with" becomes null (missing key algorithm).
+ */
+ @Test
+ void testResolveServerEndPointHashAlgorithm_SHA256with_null()
+ {
+ assertNull(provider.resolveServerEndPointHashAlgorithm("SHA256with"), "Resolution of 'SHA256with' (missing key algorithm) did not yield null as expected.");
+ }
+
+ /**
+ * Verifies resolution: "withRSA" becomes null (missing hash algorithm).
+ */
+ @Test
+ void testResolveServerEndPointHashAlgorithm_withRSA_null()
+ {
+ assertNull(provider.resolveServerEndPointHashAlgorithm("withRSA"), "Resolution of 'withRSA' (missing hash algorithm) did not yield null as expected.");
+ }
+
+ /**
+ * Verifies that substituteWeakServerEndPointHashAlgorithm substitutes MD5 with SHA-256.
+ */
+ @Test
+ void testSubstituteWeakServerEndPointHashAlgorithm_MD5()
+ {
+ assertEquals("SHA-256", provider.substituteWeakServerEndPointHashAlgorithm("MD5"), "MD5 should be substituted with SHA-256");
+ }
+
+ /**
+ * Verifies that substituteWeakServerEndPointHashAlgorithm substitutes SHA1 with SHA-256.
+ */
+ @Test
+ void testSubstituteWeakServerEndPointHashAlgorithm_SHA1()
+ {
+ assertEquals("SHA-256", provider.substituteWeakServerEndPointHashAlgorithm("SHA1"), "SHA1 should be substituted with SHA-256");
+ }
+
+ /**
+ * Verifies that substituteWeakServerEndPointHashAlgorithm substitutes SHA-1 with SHA-256.
+ */
+ @Test
+ void testSubstituteWeakServerEndPointHashAlgorithm_SHA_1()
+ {
+ assertEquals("SHA-256", provider.substituteWeakServerEndPointHashAlgorithm("SHA-1"), "SHA-1 should be substituted with SHA-256");
+ }
+
+ /**
+ * Verifies that substituteWeakServerEndPointHashAlgorithm does not substitute SHA-512.
+ */
+ @Test
+ void testSubstituteWeakServerEndPointHashAlgorithm_SHA_512()
+ {
+ assertEquals("SHA-512", provider.substituteWeakServerEndPointHashAlgorithm("SHA-512"), "SHA-512 should not be substituted");
+ }
+
+ /**
+ * Verifies that substituteWeakServerEndPointHashAlgorithm does not substitute SHA3-256.
+ */
+ @Test
+ void testSubstituteWeakServerEndPointHashAlgorithm_SHA3_256()
+ {
+ assertEquals("SHA3-256", provider.substituteWeakServerEndPointHashAlgorithm("SHA3-256"), "SHA3-256 should not be substituted");
+ }
+
+ /**
+ * Verifies that Ed25519 (no hash) returns null for channel binding hash algorithm.
+ */
+ @Test
+ void testResolveServerEndPointHashAlgorithm_Ed25519_returnsNull()
+ {
+ assertNull(provider.resolveServerEndPointHashAlgorithm("Ed25519"), "Ed25519 should yield null (undefined) for channel binding hash algorithm");
+ }
+
+ /**
+ * Verifies that extractPssHashAlgorithm returns null if PSS parameters are garbage (parsing fails).
+ */
+ @Test
+ void testExtractPssHashAlgorithm_garbageParams_returnsNull()
+ {
+ final X509Certificate cert = mock(X509Certificate.class);
+ // Provide invalid/garbage params
+ when(cert.getSigAlgParams()).thenReturn(new byte[] {1,2,3,4,5,6,7,8,9});
+ assertNull(provider.extractPssHashAlgorithm(cert), "Should return null when PSS parameters are garbage");
+ }
+
+ /**
+ * Verifies that weird signature names/casing are handled correctly.
+ */
+ @Test
+ void testResolveServerEndPointHashAlgorithm_weirdCasing()
+ {
+ assertEquals("SHA-256", provider.resolveServerEndPointHashAlgorithm("SHA256WITHRSA"), "Should normalize and resolve weird casing 'SHA256WITHRSA'");
+ assertEquals("SHA-256", provider.resolveServerEndPointHashAlgorithm("sha256withrsa"), "Should normalize and resolve lower-case 'sha256withrsa'");
+ assertEquals("SHA-256", provider.resolveServerEndPointHashAlgorithm("Sha256WithRsa"), "Should normalize and resolve mixed-case 'Sha256WithRsa'");
+ }
+}