diff --git a/.agents/skills/a2a-workflow/SKILL.md b/.agents/skills/a2a-workflow/SKILL.md
index 94b124b..12c6dfb 100644
--- a/.agents/skills/a2a-workflow/SKILL.md
+++ b/.agents/skills/a2a-workflow/SKILL.md
@@ -81,6 +81,26 @@ ISafeguardA2AContext fromKeystore = Safeguard.A2A.getContext(
Windows thumbprint overloads require `SunMSCAPI` to be available. The SDK throws a
`SafeguardForJavaException` on non-Windows platforms or when the provider is missing.
+### TLS 1.3, the Cert SNI hostname, and the JSSE post-handshake limitation
+
+A2A is certificate-authenticated, so TLS version matters. SafeguardJava negotiates
+**TLS 1.2 only by default**. On Safeguard 9.0 (which enables TLS 1.3), A2A/cert-auth
+over TLS 1.3 fails on the **Standard binding** with `60094 Authorization is denied`,
+because the server requests the client certificate *post-handshake* (RFC 8446
+§4.6.2) and Java's JSSE never presents a certificate in response. This is a Java
+**platform** limitation (verified on JDK 11 and JDK 21), not an SDK bug — no
+SafeguardJava setting or JVM flag makes post-handshake client auth work. TLS 1.2
+keeps working because the certificate request happens differently.
+
+- **Default (TLS 1.2):** A2A works against 9.0 with no extra configuration.
+- **TLS 1.3 A2A/cert-auth:** connect to the appliance **Cert SNI hostname**, where
+ the certificate is requested *in-handshake*. Then opt into 1.3:
+ `Safeguard.setMaxTlsVersion(TlsVersion.TLSv1_3)` (or
+ `setMinTlsVersion(TlsVersion.TLSv1_3)` to require it), or the
+ `safeguard.tls.min/maxVersion` system properties. Configure this **before**
+ calling `getContext(...)`; it is process-wide.
+- Requests stay on **HTTP/1.1** (HTTP/2 disallows the post-handshake request).
+
## 3. Credential retrieval (programmatic access)
### Enumerate retrievable accounts
@@ -266,4 +286,5 @@ Troubleshooting checklist:
4. use `getRetrievableAccounts()` to prove what the certificate can actually see
5. switch from a transient listener to a persistent listener if outages matter
6. avoid `ignoreSsl=true` outside lab scenarios
-7. clear API keys, passwords, and retrieved secrets from memory when finished
+7. on Safeguard 9.0, if cert-auth returns `60094 Authorization is denied` only when TLS 1.3 is negotiated, use the Cert SNI hostname for 1.3 or keep the default TLS 1.2 (JSSE cannot present a client cert post-handshake)
+8. clear API keys, passwords, and retrieved secrets from memory when finished
diff --git a/.agents/skills/api-patterns/SKILL.md b/.agents/skills/api-patterns/SKILL.md
index be2feb9..aecde42 100644
--- a/.agents/skills/api-patterns/SKILL.md
+++ b/.agents/skills/api-patterns/SKILL.md
@@ -137,6 +137,17 @@ If you expect a long-running process, wrap the connection with `Safeguard.Persis
`PersistentSafeguardConnection` checks `getAccessTokenLifetimeRemaining()` before each
`invokeMethod*` call and refreshes expired tokens automatically.
+### TLS version (default 1.2, opt-in 1.3)
+
+All connections negotiate **TLS 1.2 only** by default. Password/token auth can safely
+use TLS 1.3 on the Standard binding; certificate/A2A auth over TLS 1.3 requires the
+appliance **Cert SNI hostname** because JSSE cannot present a client certificate
+post-handshake. Opt into 1.3 process-wide **before** calling `connect(...)`:
+`Safeguard.setMaxTlsVersion(TlsVersion.TLSv1_3)` /
+`Safeguard.setMinTlsVersion(TlsVersion.TLSv1_3)`, or the
+`safeguard.tls.min/maxVersion` system properties. See the README "TLS Protocol
+Versions" section and the `a2a-workflow` skill for the full rationale.
+
### Management service calls
`Service.Management` is only valid on a management connection:
diff --git a/AGENTS.md b/AGENTS.md
index 4401dd7..c2fefa2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -51,6 +51,7 @@ See `testing-guide` for setup and workflow details.
- expect `ArgumentException`, `SafeguardForJavaException`, and `ObjectDisposedException`
- preserve Java 8 compatibility and standard Java naming
- do not recommend `ignoreSsl=true` for production without a warning
+- default TLS is **1.2 only** across all transports; TLS 1.3 is opt-in via `Safeguard.setMin/MaxTlsVersion(TlsVersion)` or `safeguard.tls.min/maxVersion` system properties. JSSE has no client post-handshake auth, so TLS 1.3 cert/A2A auth requires the appliance Cert SNI hostname
- keep repository text files on **LF** line endings, especially on Windows
## CI/CD
diff --git a/README.md b/README.md
index dd0377d..a2481e0 100644
--- a/README.md
+++ b/README.md
@@ -301,11 +301,12 @@ public class CertificateValidator implements HostnameVerifier {
### TLS Certificate Verification and the `ignoreSsl` Flag
Every `Safeguard.connect` / `Safeguard.A2A.GetContext` overload accepts an
-`ignoreSsl` (`boolean`) parameter. The SDK pins the minimum TLS version to
-**TLS 1.2** in all transports (REST and SignalR), regardless of this flag —
-weak TLS versions are never negotiated. What `ignoreSsl` controls is
-**X.509 certificate chain validation**, not the TLS version and not hostname
-verification on its own.
+`ignoreSsl` (`boolean`) parameter. By default the SDK negotiates **TLS 1.2**
+in all transports (REST and SignalR); weaker versions (TLS 1.0/1.1) are never
+enabled, and TLS 1.3 is available as an opt-in (see
+[TLS Protocol Versions](#tls-protocol-versions-tls-13-support) below). What
+`ignoreSsl` controls is **X.509 certificate chain validation**, not the TLS
+version and not hostname verification on its own.
| Setting | Chain validation | Hostname verification | Recommended use |
|---|---|---|---|
@@ -331,6 +332,69 @@ the flag is an explicit opt-in — by the time a caller passes `true`, the
trade-off has already been accepted. The responsibility for production
hardening lies with the integrating application.
+### TLS Protocol Versions (TLS 1.3 Support)
+
+Safeguard 9.0 (Windows 11 base OS) enables **TLS 1.3**. By default SafeguardJava
+negotiates **TLS 1.2 only** across every transport (REST and SignalR). This is a
+deliberate default, not just legacy behavior:
+
+> **⚠️ Java limitation — no TLS 1.3 post-handshake client authentication.**
+> Java's TLS engine (JSSE) **does not** present a client certificate in response
+> to a TLS 1.3 post-handshake `CertificateRequest` (RFC 8446 §4.6.2). This has
+> been verified on JDK 11 and JDK 21 and is a limitation of the Java platform
+> itself, **not** of this SDK — there is no SafeguardJava setting or JVM flag that
+> makes it work. As a direct consequence, **certificate-based and A2A
+> authentication cannot use TLS 1.3 on the appliance Standard binding**; they must
+> either run over TLS 1.2 (the default) or connect to the appliance **Cert SNI
+> hostname** (see below). Password/token authentication is unaffected.
+
+- **JSSE cannot present a client certificate post-handshake.** On TLS 1.3 with
+ the appliance **Standard binding**, the server requests the client certificate
+ *after* the handshake (post-handshake authentication, RFC 8446 §4.6.2). Java's
+ JSSE never answers that request, so certificate/A2A authentication fails on a
+ TLS 1.3 connection (`60094 Authorization is denied`) while succeeding on
+ TLS 1.2. Keeping the default at TLS 1.2 keeps cert-auth working out of the box.
+- **Password/token authentication** carries no client certificate and can use
+ TLS 1.3 on the Standard binding without issue.
+- **The only route to TLS 1.3 certificate/A2A auth** with this SDK is to connect
+ to the appliance **Cert SNI hostname**, where the certificate is requested
+ *in-handshake* (no post-handshake step). Password auth can also use it.
+- Requests use **HTTP/1.1** (HTTP/2 disallows the post-handshake
+ `CertificateRequest`); this is unchanged.
+
+You can raise (or pin) the allowed versions with an opt-in minimum/maximum bound.
+The setting is process-wide and read when each connection or listener is created,
+so configure it **before** calling `Safeguard.connect(...)`:
+
+```java
+import com.oneidentity.safeguard.safeguardjava.Safeguard;
+import com.oneidentity.safeguard.safeguardjava.TlsVersion;
+
+// Allow TLS 1.3 in addition to 1.2 (e.g. password/token auth on the Standard
+// binding, or cert-auth against the Cert SNI hostname):
+Safeguard.setMaxTlsVersion(TlsVersion.TLSv1_3);
+
+// Require TLS 1.3 only:
+Safeguard.setMinTlsVersion(TlsVersion.TLSv1_3);
+
+// Restore the default (TLS 1.2 only):
+Safeguard.setMaxTlsVersion(null);
+```
+
+For an interim rollout with no code change, the same bounds can be supplied as
+JVM system properties (programmatic settings take precedence):
+
+```
+-Dsafeguard.tls.minVersion=TLSv1.2 -Dsafeguard.tls.maxVersion=TLSv1.3
+```
+
+| Configuration | Enabled versions |
+|---|---|
+| Default (both unset) | `TLSv1.2` |
+| `setMaxTlsVersion(TLSv1_3)` | `TLSv1.2`, `TLSv1.3` |
+| `setMinTlsVersion(TLSv1_3)` | `TLSv1.3` |
+| `setMin/MaxTlsVersion(TLSv1_2)` | `TLSv1.2` |
+
### Installation
SafeguardJava is available from [Maven Central](https://central.sonatype.com/artifact/com.oneidentity.safeguard/safeguardjava)
diff --git a/pipeline-templates/global-variables.yml b/pipeline-templates/global-variables.yml
index 8f75f1f..ca62c4c 100644
--- a/pipeline-templates/global-variables.yml
+++ b/pipeline-templates/global-variables.yml
@@ -1,6 +1,6 @@
variables:
- name: semanticVersion
- value: '8.2.4'
+ value: '8.4.0'
- name: isTagBuild
value: ${{ startsWith(variables['Build.SourceBranch'], 'refs/tags/') }}
- name: isPrerelease
diff --git a/pom.xml b/pom.xml
index 652b01e..8171df1 100644
--- a/pom.xml
+++ b/pom.xml
@@ -13,7 +13,7 @@
This is an opt-in, process-wide setting. When left unset (and no + * {@code safeguard.tls.minVersion} system property is present), the SDK + * negotiates TLS 1.2 only — the historical default that keeps + * certificate/A2A authentication working on the appliance Standard binding + * (JSSE cannot present a client certificate post-handshake, so TLS 1.3 + * cert-auth is only possible via the appliance Cert SNI hostname). + * + *
Password/token authentication can safely negotiate TLS 1.3 on the + * Standard binding. To require TLS 1.3, call + * {@code setMinTlsVersion(TlsVersion.TLSv1_3)}. + * + * @param minTlsVersion Minimum TLS version, or {@code null} to clear. + * @throws IllegalArgumentException If it is higher than a configured maximum. + */ + public static void setMinTlsVersion(TlsVersion minTlsVersion) { + TlsConfiguration.setMinTlsVersion(minTlsVersion); + } + + /** + * Get the programmatically configured minimum TLS protocol version. + * + * @return Configured minimum, or {@code null} when unset (does not reflect + * the {@code safeguard.tls.minVersion} system-property fallback). + */ + public static TlsVersion getMinTlsVersion() { + return TlsConfiguration.getMinTlsVersion(); + } + + /** + * Set the maximum TLS protocol version SafeguardJava is allowed to + * negotiate on all subsequently created connections and event listeners. + * + *
This is an opt-in, process-wide setting. When left unset (and no + * {@code safeguard.tls.maxVersion} system property is present), the SDK + * negotiates TLS 1.2 only. Raise it to + * {@code TlsVersion.TLSv1_3} to allow TLS 1.3. Note that certificate/A2A + * authentication over TLS 1.3 requires connecting to the appliance Cert SNI + * hostname, because JSSE cannot present a client certificate in response to + * a TLS 1.3 post-handshake {@code CertificateRequest}. + * + * @param maxTlsVersion Maximum TLS version, or {@code null} to clear. + * @throws IllegalArgumentException If it is lower than a configured minimum. + */ + public static void setMaxTlsVersion(TlsVersion maxTlsVersion) { + TlsConfiguration.setMaxTlsVersion(maxTlsVersion); + } + + /** + * Get the programmatically configured maximum TLS protocol version. + * + * @return Configured maximum, or {@code null} when unset (does not reflect + * the {@code safeguard.tls.maxVersion} system-property fallback). + */ + public static TlsVersion getMaxTlsVersion() { + return TlsConfiguration.getMaxTlsVersion(); + } + /** * Connect to Safeguard API using an API access token. * diff --git a/src/main/java/com/oneidentity/safeguard/safeguardjava/TlsConfiguration.java b/src/main/java/com/oneidentity/safeguard/safeguardjava/TlsConfiguration.java new file mode 100644 index 0000000..eedde47 --- /dev/null +++ b/src/main/java/com/oneidentity/safeguard/safeguardjava/TlsConfiguration.java @@ -0,0 +1,156 @@ +package com.oneidentity.safeguard.safeguardjava; + +import java.util.ArrayList; +import java.util.List; + +/** + * Central, process-wide configuration for the TLS protocol versions that + * SafeguardJava transports (REST clients and the SignalR event listener) are + * allowed to negotiate. + * + *
Default behavior. When neither a minimum nor a maximum version is + * configured, SafeguardJava negotiates TLS 1.2 only. This preserves the + * SDK's historical behavior and, critically, keeps certificate/A2A + * authentication working on the appliance Standard binding: JSSE cannot present + * a client certificate in response to a TLS 1.3 post-handshake + * {@code CertificateRequest} (RFC 8446 §4.6.2), so cert-auth on the + * Standard binding only succeeds at TLS 1.2. + * + *
Opting into TLS 1.3. Callers may raise the maximum (and/or minimum) + * version via {@link #setMaxTlsVersion(TlsVersion)} / + * {@link #setMinTlsVersion(TlsVersion)} (surfaced publicly as + * {@link Safeguard#setMaxTlsVersion(TlsVersion)} / + * {@link Safeguard#setMinTlsVersion(TlsVersion)}), or via the + * {@value #MAX_TLS_VERSION_PROPERTY} / {@value #MIN_TLS_VERSION_PROPERTY} system + * properties for an interim, no-code-change rollout. Programmatic settings take + * precedence over system properties. + * + *
Password/token authentication (which carries no client certificate) can + * use TLS 1.3 on the Standard binding without issue. Certificate/A2A + * authentication over TLS 1.3 additionally requires connecting to the appliance + * Cert SNI hostname, where the certificate is requested in-handshake. + * + *
This class is thread-safe; the configured versions are held in + * {@code volatile} fields and read at connection-creation time. + */ +public final class TlsConfiguration { + + /** System property that supplies the minimum TLS version when no value has + * been set programmatically. Accepts {@code TLSv1.2}, {@code TLSv1_2}, + * {@code 1.2}, etc. (see {@link TlsVersion#fromString(String)}). */ + public static final String MIN_TLS_VERSION_PROPERTY = "safeguard.tls.minVersion"; + + /** System property that supplies the maximum TLS version when no value has + * been set programmatically. Accepts {@code TLSv1.3}, {@code TLSv1_3}, + * {@code 1.3}, etc. (see {@link TlsVersion#fromString(String)}). */ + public static final String MAX_TLS_VERSION_PROPERTY = "safeguard.tls.maxVersion"; + + private static volatile TlsVersion minTlsVersion = null; + private static volatile TlsVersion maxTlsVersion = null; + + private TlsConfiguration() { + } + + /** + * Sets the minimum TLS protocol version the SDK is allowed to negotiate, or + * {@code null} to defer to the {@value #MIN_TLS_VERSION_PROPERTY} system + * property (and ultimately the default). + * + * @param version the minimum version, or {@code null} to clear. + * @throws IllegalArgumentException if a maximum is already configured and + * {@code version} is higher than it. + */ + public static void setMinTlsVersion(TlsVersion version) { + if (version != null && maxTlsVersion != null && version.ordinal() > maxTlsVersion.ordinal()) { + throw new IllegalArgumentException(String.format( + "Minimum TLS version %s cannot be higher than the configured maximum %s", + version.getProtocolName(), maxTlsVersion.getProtocolName())); + } + minTlsVersion = version; + } + + /** + * @return the programmatically configured minimum TLS version, or + * {@code null} if unset. Does not reflect the system-property + * fallback. + */ + public static TlsVersion getMinTlsVersion() { + return minTlsVersion; + } + + /** + * Sets the maximum TLS protocol version the SDK is allowed to negotiate, or + * {@code null} to defer to the {@value #MAX_TLS_VERSION_PROPERTY} system + * property (and ultimately the default). + * + * @param version the maximum version, or {@code null} to clear. + * @throws IllegalArgumentException if a minimum is already configured and + * {@code version} is lower than it. + */ + public static void setMaxTlsVersion(TlsVersion version) { + if (version != null && minTlsVersion != null && version.ordinal() < minTlsVersion.ordinal()) { + throw new IllegalArgumentException(String.format( + "Maximum TLS version %s cannot be lower than the configured minimum %s", + version.getProtocolName(), minTlsVersion.getProtocolName())); + } + maxTlsVersion = version; + } + + /** + * @return the programmatically configured maximum TLS version, or + * {@code null} if unset. Does not reflect the system-property + * fallback. + */ + public static TlsVersion getMaxTlsVersion() { + return maxTlsVersion; + } + + private static TlsVersion effectiveMin() { + return (minTlsVersion != null) ? minTlsVersion + : TlsVersion.fromString(System.getProperty(MIN_TLS_VERSION_PROPERTY)); + } + + private static TlsVersion effectiveMax() { + return (maxTlsVersion != null) ? maxTlsVersion + : TlsVersion.fromString(System.getProperty(MAX_TLS_VERSION_PROPERTY)); + } + + /** + * Resolves the ordered set of JSSE protocol names that transports should + * enable, honoring the configured (or system-property) minimum and maximum + * bounds. + * + *
When neither bound is set, this returns {@code ["TLSv1.2"]} (the legacy
+ * default). When at least one bound is set, the range spans
+ * {@code [min or TLSv1.2 .. max or TLSv1.3]}.
+ *
+ * @return a non-empty array of JSSE protocol names, lowest version first.
+ * @throws IllegalStateException if the resolved minimum is higher than the
+ * resolved maximum (only reachable via inconsistent system
+ * properties).
+ */
+ public static String[] resolveEnabledProtocolNames() {
+ TlsVersion min = effectiveMin();
+ TlsVersion max = effectiveMax();
+
+ if (min == null && max == null) {
+ return new String[] { TlsVersion.TLSv1_2.getProtocolName() };
+ }
+
+ TlsVersion lo = (min != null) ? min : TlsVersion.TLSv1_2;
+ TlsVersion hi = (max != null) ? max : TlsVersion.TLSv1_3;
+ if (lo.ordinal() > hi.ordinal()) {
+ throw new IllegalStateException(String.format(
+ "Invalid TLS version range: minimum %s is higher than maximum %s",
+ lo.getProtocolName(), hi.getProtocolName()));
+ }
+
+ List The SDK exposes an opt-in minimum/maximum TLS version bound (see
+ * {@link Safeguard#setMinTlsVersion(TlsVersion)} and
+ * {@link Safeguard#setMaxTlsVersion(TlsVersion)}). Only the two versions
+ * relevant to modern Safeguard appliances are offered; TLS 1.0 and 1.1 are
+ * never enabled by the SDK.
+ *
+ * The enum declaration order (lowest to highest) is significant: the range
+ * resolver in {@link TlsConfiguration} relies on {@link #ordinal()} to compare
+ * versions.
+ */
+public enum TlsVersion {
+
+ /** TLS 1.2 — the SafeguardJava default and the only version that
+ * supports certificate/A2A authentication on the Standard binding. */
+ TLSv1_2("TLSv1.2"),
+
+ /** TLS 1.3 — opt-in. Certificate/A2A authentication over TLS 1.3
+ * requires connecting to the appliance Cert SNI hostname because JSSE
+ * cannot present a client certificate post-handshake. */
+ TLSv1_3("TLSv1.3");
+
+ private final String protocolName;
+
+ TlsVersion(String protocolName) {
+ this.protocolName = protocolName;
+ }
+
+ /**
+ * The JSSE protocol name for this version (e.g. {@code "TLSv1.2"}), as used
+ * by {@code SSLSocket.setEnabledProtocols(String[])}.
+ *
+ * @return the JSSE protocol name.
+ */
+ public String getProtocolName() {
+ return protocolName;
+ }
+
+ /**
+ * Parses a {@link TlsVersion} from a string, accepting the enum name
+ * ({@code TLSv1_2}), the JSSE protocol name ({@code TLSv1.2}), or a bare
+ * version number ({@code 1.2}). Parsing is case-insensitive and tolerant of
+ * surrounding whitespace. This is used to resolve the
+ * {@code safeguard.tls.minVersion} / {@code safeguard.tls.maxVersion} system
+ * properties.
+ *
+ * @param value the value to parse; may be {@code null}.
+ * @return the matching {@link TlsVersion}, or {@code null} when
+ * {@code value} is {@code null}, blank, or unrecognized.
+ */
+ public static TlsVersion fromString(String value) {
+ if (value == null) {
+ return null;
+ }
+ String v = value.trim();
+ if (v.isEmpty()) {
+ return null;
+ }
+ for (TlsVersion t : values()) {
+ if (t.name().equalsIgnoreCase(v)
+ || t.protocolName.equalsIgnoreCase(v)
+ || t.protocolName.substring("TLSv".length()).equalsIgnoreCase(v)) {
+ return t;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/oneidentity/safeguard/safeguardjava/authentication/PkceAuthenticator.java b/src/main/java/com/oneidentity/safeguard/safeguardjava/authentication/PkceAuthenticator.java
index 38d8741..0efaa6f 100644
--- a/src/main/java/com/oneidentity/safeguard/safeguardjava/authentication/PkceAuthenticator.java
+++ b/src/main/java/com/oneidentity/safeguard/safeguardjava/authentication/PkceAuthenticator.java
@@ -4,6 +4,7 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.oneidentity.safeguard.safeguardjava.AgentBasedLoginUtils;
import com.oneidentity.safeguard.safeguardjava.Utils;
+import com.oneidentity.safeguard.safeguardjava.TlsConfiguration;
import com.oneidentity.safeguard.safeguardjava.exceptions.ArgumentException;
import com.oneidentity.safeguard.safeguardjava.exceptions.ObjectDisposedException;
import com.oneidentity.safeguard.safeguardjava.exceptions.SafeguardForJavaException;
@@ -371,6 +372,7 @@ private String rstsFormPost(CloseableHttpClient httpClient, String url, String f
private CloseableHttpClient createPkceHttpClient(String appliance, String csrfToken) {
try {
SSLConnectionSocketFactory sslsf;
+ String[] enabledProtocols = TlsConfiguration.resolveEnabledProtocolNames();
if (isIgnoreSsl()) {
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, new TrustManager[]{new X509TrustManager() {
@@ -378,7 +380,7 @@ private CloseableHttpClient createPkceHttpClient(String appliance, String csrfTo
public void checkClientTrusted(X509Certificate[] certs, String authType) { }
public void checkServerTrusted(X509Certificate[] certs, String authType) { }
}}, new java.security.SecureRandom());
- sslsf = new SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE);
+ sslsf = new SSLConnectionSocketFactory(sslContext, enabledProtocols, null, NoopHostnameVerifier.INSTANCE);
} else if (getValidationCallback() != null) {
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, new TrustManager[]{new X509TrustManager() {
@@ -386,9 +388,9 @@ public void checkServerTrusted(X509Certificate[] certs, String authType) { }
public void checkClientTrusted(X509Certificate[] certs, String authType) { }
public void checkServerTrusted(X509Certificate[] certs, String authType) { }
}}, new java.security.SecureRandom());
- sslsf = new SSLConnectionSocketFactory(sslContext, getValidationCallback());
+ sslsf = new SSLConnectionSocketFactory(sslContext, enabledProtocols, null, getValidationCallback());
} else {
- sslsf = new SSLConnectionSocketFactory(SSLContext.getDefault());
+ sslsf = new SSLConnectionSocketFactory(SSLContext.getDefault(), enabledProtocols, null, (HostnameVerifier) null);
}
Registry See {@code RestClient.TLS_PROTOCOL} for rationale. Both transports
- * pin the same minimum version so the SignalR/WebSocket connection cannot
- * fall back to TLS 1.0 / 1.1 on a misconfigured JVM.
+ * The generic {@code "TLS"} algorithm is requested so the context can
+ * negotiate the highest protocol the JVM supports. The enabled
+ * versions are constrained explicitly via an OkHttp {@link okhttp3.ConnectionSpec}
+ * built from {@link TlsConfiguration#resolveEnabledProtocolNames()} (default
+ * {@code TLSv1.2} only), so this transport tracks the same TLS policy as the
+ * REST clients.
*/
- static final String TLS_PROTOCOL = "TLSv1.2";
+ static final String SSLCONTEXT_PROTOCOL = "TLS";
private boolean disposed;
@@ -413,9 +420,21 @@ private void ConfigureHttpClientBuilder(Builder builder)
// Configure the SSL Context according to options and set the
// OkHttpClient builder SSL socket factory
- SSLContext sslContext = SSLContext.getInstance(TLS_PROTOCOL);
+ SSLContext sslContext = SSLContext.getInstance(SSLCONTEXT_PROTOCOL);
sslContext.init(km, tm, null);
builder.sslSocketFactory(sslContext.getSocketFactory(), x509tm);
+
+ // Constrain the enabled TLS versions to the SDK-resolved set
+ // (default TLSv1.2 only; opt-in TLS 1.3 via TlsConfiguration) so the
+ // event transport matches the REST clients' TLS policy.
+ List Hard-pinning to {@code TLSv1.2} avoids the {@code "TLS"} alias, which
- * the JRE may resolve to TLS 1.0 or 1.1 on misconfigured JVMs. TLS 1.2 is
- * the project's Java 8 baseline minimum and is widely supported by
- * Safeguard appliances. TLS 1.3 negotiation, when supported by both peers,
- * is still permitted by the underlying SSLContext.
+ * The generic {@code "TLS"} algorithm is requested so the context is
+ * capable of the highest protocol the JVM supports. The enabled
+ * protocol versions are then constrained explicitly at the socket-factory
+ * layer via {@link TlsConfiguration#resolveEnabledProtocolNames()}, which
+ * defaults to {@code TLSv1.2} only. This avoids relying on JVM defaults
+ * (which may permit TLS 1.0/1.1) while still allowing opt-in TLS 1.3.
*/
- static final String TLS_PROTOCOL = "TLSv1.2";
+ static final String SSLCONTEXT_PROTOCOL = "TLS";
private CloseableHttpClient client = null;
private BasicCookieStore cookieStore = new BasicCookieStore();
@@ -108,9 +110,10 @@ public RestClient(String connectionAddr, boolean ignoreSsl, HostnameVerifier val
* {@code NoopHostnameVerifier} that accepts any hostname. This is
* intended for development against self-signed test appliances only;
* it leaves the connection vulnerable to man-in-the-middle attacks
- * and must not be enabled in production. The minimum TLS protocol
- * version remains pinned to {@code TLSv1.2} regardless of this flag —
- * see {@link #TLS_PROTOCOL}.
+ * and must not be enabled in production. The enabled TLS protocol
+ * versions are unaffected by this flag and default to {@code TLSv1.2}
+ * only — see
+ * {@link TlsConfiguration#resolveEnabledProtocolNames()}.
*
* For production with a self-signed or internal-CA appliance,
* prefer importing the appliance certificate into the JVM truststore
@@ -158,12 +161,12 @@ private HttpClientBuilder createClientBuilder(String connectionAddr, boolean ign
SSLConnectionSocketFactory sslsf = null;
if (ignoreSsl) {
this.validationCallback = null;
- sslsf = new SSLConnectionSocketFactory(getSSLContext(null, null, null, null), NoopHostnameVerifier.INSTANCE);
+ sslsf = buildSocketFactory(getSSLContext(null, null, null, null), NoopHostnameVerifier.INSTANCE);
} else if (validationCallback != null) {
this.validationCallback = validationCallback;
- sslsf = new SSLConnectionSocketFactory(getSSLContext(null, null, null, null), validationCallback);
+ sslsf = buildSocketFactory(getSSLContext(null, null, null, null), validationCallback);
} else {
- sslsf = new SSLConnectionSocketFactory(getSSLContext(null, null, null, null));
+ sslsf = buildSocketFactory(getSSLContext(null, null, null, null), null);
}
Registry These verify the core policy of the TLS 1.3 support work: unset =>
+ * TLS 1.2 only (preserving legacy behavior and Standard-binding cert-auth), and
+ * opt-in expansion to TLS 1.3 via programmatic setters or system properties.
+ */
+public class TlsConfigurationTest {
+
+ @Before
+ @After
+ public void reset() {
+ TlsConfiguration.setMinTlsVersion(null);
+ TlsConfiguration.setMaxTlsVersion(null);
+ System.clearProperty(TlsConfiguration.MIN_TLS_VERSION_PROPERTY);
+ System.clearProperty(TlsConfiguration.MAX_TLS_VERSION_PROPERTY);
+ }
+
+ @Test
+ public void defaultIsTls12Only() {
+ assertNull(TlsConfiguration.getMinTlsVersion());
+ assertNull(TlsConfiguration.getMaxTlsVersion());
+ assertArrayEquals(new String[] { "TLSv1.2" },
+ TlsConfiguration.resolveEnabledProtocolNames());
+ }
+
+ @Test
+ public void maxTls13OpensRangeFrom12() {
+ TlsConfiguration.setMaxTlsVersion(TlsVersion.TLSv1_3);
+ assertArrayEquals(new String[] { "TLSv1.2", "TLSv1.3" },
+ TlsConfiguration.resolveEnabledProtocolNames());
+ }
+
+ @Test
+ public void minTls13RequiresTls13Only() {
+ TlsConfiguration.setMinTlsVersion(TlsVersion.TLSv1_3);
+ assertArrayEquals(new String[] { "TLSv1.3" },
+ TlsConfiguration.resolveEnabledProtocolNames());
+ }
+
+ @Test
+ public void explicitTls12BoundsPinTo12() {
+ TlsConfiguration.setMinTlsVersion(TlsVersion.TLSv1_2);
+ TlsConfiguration.setMaxTlsVersion(TlsVersion.TLSv1_2);
+ assertArrayEquals(new String[] { "TLSv1.2" },
+ TlsConfiguration.resolveEnabledProtocolNames());
+ }
+
+ @Test
+ public void setterRejectsMinAboveMax() {
+ TlsConfiguration.setMaxTlsVersion(TlsVersion.TLSv1_2);
+ try {
+ TlsConfiguration.setMinTlsVersion(TlsVersion.TLSv1_3);
+ fail("Expected IllegalArgumentException for min > max");
+ } catch (IllegalArgumentException expected) {
+ // ok
+ }
+ }
+
+ @Test
+ public void setterRejectsMaxBelowMin() {
+ TlsConfiguration.setMinTlsVersion(TlsVersion.TLSv1_3);
+ try {
+ TlsConfiguration.setMaxTlsVersion(TlsVersion.TLSv1_2);
+ fail("Expected IllegalArgumentException for max < min");
+ } catch (IllegalArgumentException expected) {
+ // ok
+ }
+ }
+
+ @Test
+ public void systemPropertyFallbackAppliesWhenUnset() {
+ System.setProperty(TlsConfiguration.MAX_TLS_VERSION_PROPERTY, "1.3");
+ assertArrayEquals(new String[] { "TLSv1.2", "TLSv1.3" },
+ TlsConfiguration.resolveEnabledProtocolNames());
+ }
+
+ @Test
+ public void programmaticSettingOverridesSystemProperty() {
+ System.setProperty(TlsConfiguration.MAX_TLS_VERSION_PROPERTY, "1.3");
+ TlsConfiguration.setMaxTlsVersion(TlsVersion.TLSv1_2);
+ assertArrayEquals(new String[] { "TLSv1.2" },
+ TlsConfiguration.resolveEnabledProtocolNames());
+ }
+
+ @Test
+ public void inconsistentSystemPropertiesThrowOnResolve() {
+ System.setProperty(TlsConfiguration.MIN_TLS_VERSION_PROPERTY, "1.3");
+ System.setProperty(TlsConfiguration.MAX_TLS_VERSION_PROPERTY, "1.2");
+ try {
+ TlsConfiguration.resolveEnabledProtocolNames();
+ fail("Expected IllegalStateException for min > max via system properties");
+ } catch (IllegalStateException expected) {
+ // ok
+ }
+ }
+
+ @Test
+ public void fromStringAcceptsMultipleForms() {
+ assertEquals(TlsVersion.TLSv1_2, TlsVersion.fromString("TLSv1.2"));
+ assertEquals(TlsVersion.TLSv1_2, TlsVersion.fromString("TLSv1_2"));
+ assertEquals(TlsVersion.TLSv1_2, TlsVersion.fromString(" 1.2 "));
+ assertEquals(TlsVersion.TLSv1_3, TlsVersion.fromString("tlsv1.3"));
+ assertEquals(TlsVersion.TLSv1_3, TlsVersion.fromString("1.3"));
+ assertNull(TlsVersion.fromString(null));
+ assertNull(TlsVersion.fromString(""));
+ assertNull(TlsVersion.fromString("TLSv1.1"));
+ }
+}
diff --git a/src/test/java/com/oneidentity/safeguard/safeguardjava/event/SafeguardEventListenerSSLContextTest.java b/src/test/java/com/oneidentity/safeguard/safeguardjava/event/SafeguardEventListenerSSLContextTest.java
index fc8e451..874d9e9 100644
--- a/src/test/java/com/oneidentity/safeguard/safeguardjava/event/SafeguardEventListenerSSLContextTest.java
+++ b/src/test/java/com/oneidentity/safeguard/safeguardjava/event/SafeguardEventListenerSSLContextTest.java
@@ -1,32 +1,32 @@
package com.oneidentity.safeguard.safeguardjava.event;
import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.fail;
import java.lang.reflect.Field;
-import javax.net.ssl.SSLContext;
import org.junit.Test;
/**
- * Regression test: TLS version pinning.
+ * Regression test: TLS context wiring for the SignalR event listener path.
*
- * Mirror of {@code RestClientSSLContextTest} for the SignalR event
- * listener path: ensures the listener's HTTP client builder is wired with
- * an explicit {@code TLSv1.2} {@link SSLContext}, not the generic
- * {@code "TLS"} alias.
+ * The listener builds its {@code SSLContext} with the generic {@code "TLS"}
+ * algorithm and constrains the enabled versions with an OkHttp
+ * {@code ConnectionSpec} derived from {@code TlsConfiguration} (default
+ * {@code TLSv1.2} only). This test guards the context algorithm constant so the
+ * transport cannot silently revert to a hard-pinned protocol string that would
+ * block opt-in TLS 1.3.
*
- * Note: The SignalR dependency may require Java 9+ at class-load time.
- * These tests use Class.forName to detect that situation and skip gracefully
- * rather than failing the build on a Java 8 CI agent.
+ * Note: The SignalR dependency may require Java 9+ at class-load time. This
+ * test uses Class.forName to detect that situation and skip gracefully rather
+ * than failing the build on a Java 8 CI agent.
*/
public class SafeguardEventListenerSSLContextTest {
- private static final String EXPECTED_PROTOCOL = "TLSv1.2";
+ private static final String EXPECTED_CONTEXT_ALGORITHM = "TLS";
private static final String CLASS_NAME =
"com.oneidentity.safeguard.safeguardjava.event.SafeguardEventListener";
@Test
- public void tlsProtocolConstantIsPinnedToTls12() throws Exception {
+ public void sslContextAlgorithmConstantIsGenericTls() throws Exception {
Class> clazz;
try {
clazz = Class.forName(CLASS_NAME);
@@ -35,28 +35,10 @@ public void tlsProtocolConstantIsPinnedToTls12() throws Exception {
System.out.println("SKIP: " + e.getMessage());
return;
}
- Field f = clazz.getDeclaredField("TLS_PROTOCOL");
+ Field f = clazz.getDeclaredField("SSLCONTEXT_PROTOCOL");
f.setAccessible(true);
Object value = f.get(null);
- assertEquals("SafeguardEventListener.TLS_PROTOCOL must be pinned to TLSv1.2",
- EXPECTED_PROTOCOL, value);
- }
-
- @Test
- public void sslContextProtocolIsTls12() throws Exception {
- Class> clazz;
- try {
- clazz = Class.forName(CLASS_NAME);
- } catch (UnsupportedClassVersionError e) {
- // SignalR dependency requires Java 9+; skip on Java 8 CI
- System.out.println("SKIP: " + e.getMessage());
- return;
- }
- Field f = clazz.getDeclaredField("TLS_PROTOCOL");
- f.setAccessible(true);
- String protocol = (String) f.get(null);
- SSLContext ctx = SSLContext.getInstance(protocol);
- assertEquals("SafeguardEventListener must request TLSv1.2, not generic TLS",
- EXPECTED_PROTOCOL, ctx.getProtocol());
+ assertEquals("SafeguardEventListener.SSLCONTEXT_PROTOCOL must be the generic \"TLS\" algorithm",
+ EXPECTED_CONTEXT_ALGORITHM, value);
}
}
diff --git a/src/test/java/com/oneidentity/safeguard/safeguardjava/restclient/RestClientSSLContextTest.java b/src/test/java/com/oneidentity/safeguard/safeguardjava/restclient/RestClientSSLContextTest.java
index 533aacd..f05e6af 100644
--- a/src/test/java/com/oneidentity/safeguard/safeguardjava/restclient/RestClientSSLContextTest.java
+++ b/src/test/java/com/oneidentity/safeguard/safeguardjava/restclient/RestClientSSLContextTest.java
@@ -10,42 +10,43 @@
import org.junit.Test;
/**
- * Regression test: TLS version pinning.
+ * Regression test: TLS context wiring for the REST transport.
*
- * Ensures that {@link RestClient} requests an explicit {@code TLSv1.2}
- * {@link SSLContext} rather than the generic {@code "TLS"} protocol string.
- * The generic alias can resolve to TLS 1.0 / 1.1 on misconfigured JVMs;
- * pinning the version at the SDK layer guarantees a TLS 1.2+ handshake
- * regardless of {@code jdk.tls.disabledAlgorithms}.
- *
- * This test exercises the actual private {@code getSSLContext} method via
- * reflection so that any future change of the protocol string is caught.
+ * {@link RestClient} builds its {@link SSLContext} with the generic
+ * {@code "TLS"} algorithm (so the context is capable of the JVM's highest
+ * protocol) and then constrains the enabled protocol versions at the
+ * socket-factory layer via {@code TlsConfiguration}. The enabled-version policy
+ * (default {@code TLSv1.2} only) is covered by
+ * {@link com.oneidentity.safeguard.safeguardjava.TlsConfigurationTest}; this
+ * test guards the context algorithm and the private {@code getSSLContext} call
+ * site against accidental regressions.
*/
public class RestClientSSLContextTest {
- private static final String EXPECTED_PROTOCOL = "TLSv1.2";
+ private static final String EXPECTED_CONTEXT_ALGORITHM = "TLS";
/**
- * Verifies that the package-private {@code TLS_PROTOCOL} constant is
- * pinned to TLS 1.2 (the Java 8 baseline minimum) so the source of truth
- * for the handshake version is auditable in one place.
+ * Verifies that the package-private {@code SSLCONTEXT_PROTOCOL} constant is
+ * the generic {@code "TLS"} algorithm, keeping the source of truth for the
+ * context algorithm auditable in one place.
*/
@Test
- public void tlsProtocolConstantIsPinnedToTls12() throws Exception {
- Field f = RestClient.class.getDeclaredField("TLS_PROTOCOL");
+ public void sslContextAlgorithmConstantIsGenericTls() throws Exception {
+ Field f = RestClient.class.getDeclaredField("SSLCONTEXT_PROTOCOL");
f.setAccessible(true);
Object value = f.get(null);
- assertEquals("RestClient.TLS_PROTOCOL must be pinned to TLSv1.2",
- EXPECTED_PROTOCOL, value);
+ assertEquals("RestClient.SSLCONTEXT_PROTOCOL must be the generic \"TLS\" algorithm",
+ EXPECTED_CONTEXT_ALGORITHM, value);
}
/**
- * Verifies that the SSLContext produced by RestClient.getSSLContext
- * reports protocol "TLSv1.2". This catches regressions where the
- * constant is correct but the call site reverts to a different string.
+ * Verifies that the SSLContext produced by RestClient.getSSLContext is
+ * initialized and reports the generic {@code "TLS"} algorithm. This catches
+ * regressions where the call site reverts to a hard-pinned protocol string,
+ * which would prevent opt-in TLS 1.3 from ever being negotiated.
*/
@Test
- public void sslContextProtocolIsTls12() throws Exception {
+ public void sslContextReportsGenericTlsAlgorithm() throws Exception {
RestClient client = new RestClient(
"https://127.0.0.1:9999",
true, // ignoreSsl — exercises the trust-all path
@@ -60,8 +61,8 @@ public void sslContextProtocolIsTls12() throws Exception {
m.setAccessible(true);
SSLContext ctx = (SSLContext) m.invoke(client, null, null, null, null);
- assertNotNull("getSSLContext returned null — TLSv1.2 unsupported on this JVM?", ctx);
- assertEquals("RestClient must request TLSv1.2, not generic TLS",
- EXPECTED_PROTOCOL, ctx.getProtocol());
+ assertNotNull("getSSLContext returned null — TLS unsupported on this JVM?", ctx);
+ assertEquals("RestClient must build a generic TLS context and constrain versions at the socket factory",
+ EXPECTED_CONTEXT_ALGORITHM, ctx.getProtocol());
}
}