From 4e37f42ce8312683dc5d1a64e73b151e8d4b84e2 Mon Sep 17 00:00:00 2001 From: Mihir Pradhan Date: Thu, 2 Jul 2026 09:39:16 -0500 Subject: [PATCH] Add builder option to disable CA certificate pinning Co-Authored-By: Claude Opus 4.6 --- .../src/main/java/com/duosecurity/Client.java | 25 +++++++++++- .../com/duosecurity/service/DuoConnector.java | 15 ++++--- .../test/java/com/duosecurity/ClientTest.java | 34 ++++++++++++++++ .../duosecurity/service/DuoConnectorTest.java | 40 +++++++++++++++++++ 4 files changed, 107 insertions(+), 7 deletions(-) diff --git a/duo-universal-sdk/src/main/java/com/duosecurity/Client.java b/duo-universal-sdk/src/main/java/com/duosecurity/Client.java index ad38d28..79b2280 100644 --- a/duo-universal-sdk/src/main/java/com/duosecurity/Client.java +++ b/duo-universal-sdk/src/main/java/com/duosecurity/Client.java @@ -161,6 +161,7 @@ public static class Builder { private final String redirectUri; private Boolean useDuoCodeAttribute; private String[] caCerts; + private boolean caPinningDisabled; private String userAgent; private static final String[] DEFAULT_CA_CERTS = { @@ -361,6 +362,11 @@ public Builder(String clientId, String clientSecret, String proxyHost, public Client build() throws DuoException { validateClientParams(clientId, clientSecret, apiHost, redirectUri); + if (caPinningDisabled && caCerts != DEFAULT_CA_CERTS) { + throw new DuoException( + "Cannot both disable CA pinning and provide custom certificates"); + } + Client client = new Client(); client.clientId = clientId; client.clientSecret = clientSecret; @@ -368,7 +374,8 @@ public Client build() throws DuoException { client.redirectUri = redirectUri; client.useDuoCodeAttribute = useDuoCodeAttribute; client.userAgent = userAgent; - client.duoConnector = new DuoConnector(apiHost, proxyHost, proxyPort, caCerts); + client.duoConnector = new DuoConnector(apiHost, proxyHost, proxyPort, + caPinningDisabled ? null : caCerts); return client; } @@ -377,7 +384,7 @@ public Client build() throws DuoException { * Optionally use custom CA Certificates when validating connections to Duo. * * @param userCaCerts List of CA Certificates to use - * + * * @return the Builder */ public Builder setCACerts(String[] userCaCerts) { @@ -387,6 +394,20 @@ public Builder setCACerts(String[] userCaCerts) { return this; } + /** + * Disable certificate pinning for connections to Duo. + * When disabled, TLS verification still occurs using the system's default + * trusted certificate authorities, but the bundled CA pin set is not enforced. + * + *

This option is mutually exclusive with {@link #setCACerts(String[])}. + * + * @return the Builder + */ + public Builder disableCaPinning() { + this.caPinningDisabled = true; + return this; + } + /** * Optionally toggle the returned authorization parameter to use duo_code vs code. * Defaults true to use duo_code. diff --git a/duo-universal-sdk/src/main/java/com/duosecurity/service/DuoConnector.java b/duo-universal-sdk/src/main/java/com/duosecurity/service/DuoConnector.java index d19acac..353ba7c 100644 --- a/duo-universal-sdk/src/main/java/com/duosecurity/service/DuoConnector.java +++ b/duo-universal-sdk/src/main/java/com/duosecurity/service/DuoConnector.java @@ -54,27 +54,32 @@ public DuoConnector(String apiHost, String[] caCerts) throws DuoException { * @param apiHost This value is the api host provided by Duo in the admin panel. * @param proxyHost This value is the proxy server hostname * @param proxyPort This value is the proxy server port - * @param caCerts CA Certificates used to connect to Duo + * @param caCerts CA Certificates used to connect to Duo, or null to disable pinning * * @throws DuoException For issues getting and validating the URL */ public DuoConnector(String apiHost, String proxyHost, Integer proxyPort, String[] caCerts) throws DuoException { - CertificatePinner duoCertificatePinner = new CertificatePinner.Builder() - .add(apiHost, caCerts).build(); + CertificatePinner certificatePinner; + if (caCerts != null) { + certificatePinner = new CertificatePinner.Builder() + .add(apiHost, caCerts).build(); + } else { + certificatePinner = CertificatePinner.DEFAULT; + } ConnectionPool connectionPool = new ConnectionPool( MAX_IDLE_CONNECTIONS, CONNECTION_KEEP_ALIVE_SECONDS, TimeUnit.SECONDS); OkHttpClient client; if (proxyHost != null && proxyPort != null) { Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort)); client = new OkHttpClient.Builder() - .certificatePinner(duoCertificatePinner) + .certificatePinner(certificatePinner) .connectionPool(connectionPool) .proxy(proxy) .build(); } else { client = new OkHttpClient.Builder() - .certificatePinner(duoCertificatePinner) + .certificatePinner(certificatePinner) .connectionPool(connectionPool) .build(); } diff --git a/duo-universal-sdk/src/test/java/com/duosecurity/ClientTest.java b/duo-universal-sdk/src/test/java/com/duosecurity/ClientTest.java index b412b61..9d35dc1 100644 --- a/duo-universal-sdk/src/test/java/com/duosecurity/ClientTest.java +++ b/duo-universal-sdk/src/test/java/com/duosecurity/ClientTest.java @@ -236,6 +236,40 @@ void custom_useragent() throws DuoException { assertTrue(sentUserAgent.startsWith("duo_universal_java") && sentUserAgent.endsWith(appendedUserAgent)); } + @Test + void disableCaPinning_builds_successfully() throws DuoException { + Client client = new Client.Builder(CLIENT_ID, CLIENT_SECRET, API_HOST, HTTPS_REDIRECT_URI) + .disableCaPinning() + .build(); + assertNotNull(client); + } + + @Test + void disableCaPinning_with_custom_certs_throws_exception() { + try { + new Client.Builder(CLIENT_ID, CLIENT_SECRET, API_HOST, HTTPS_REDIRECT_URI) + .setCACerts(new String[]{"sha256/test"}) + .disableCaPinning() + .build(); + Assertions.fail(); + } catch (DuoException e) { + assertTrue(e.getMessage().contains("Cannot both disable CA pinning and provide custom certificates")); + } + } + + @Test + void disableCaPinning_then_custom_certs_throws_exception() { + try { + new Client.Builder(CLIENT_ID, CLIENT_SECRET, API_HOST, HTTPS_REDIRECT_URI) + .disableCaPinning() + .setCACerts(new String[]{"sha256/test"}) + .build(); + Assertions.fail(); + } catch (DuoException e) { + assertTrue(e.getMessage().contains("Cannot both disable CA pinning and provide custom certificates")); + } + } + @Test void legacy_constructors_match() throws DuoException { // Create clients using the old deprecated constructors and check that their fields are the same as one created using the builder. diff --git a/duo-universal-sdk/src/test/java/com/duosecurity/service/DuoConnectorTest.java b/duo-universal-sdk/src/test/java/com/duosecurity/service/DuoConnectorTest.java index 33c0b2e..3bfabaa 100644 --- a/duo-universal-sdk/src/test/java/com/duosecurity/service/DuoConnectorTest.java +++ b/duo-universal-sdk/src/test/java/com/duosecurity/service/DuoConnectorTest.java @@ -3,6 +3,8 @@ import com.duosecurity.exception.DuoException; import com.duosecurity.model.HealthCheckResponse; import com.duosecurity.model.TokenResponse; +import okhttp3.CertificatePinner; +import okhttp3.OkHttpClient; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -11,6 +13,7 @@ import retrofit2.Retrofit; import java.io.IOException; +import java.lang.reflect.Field; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.when; @@ -62,6 +65,43 @@ void duoHealthcheck_network_failure() throws IOException, DuoException { } } + @Test + void constructor_with_null_caCerts_disables_pinning() throws Exception { + DuoConnector duoConnector = new DuoConnector(API_HOST, null); + OkHttpClient client = getOkHttpClient(duoConnector); + assertTrue(getPins(client.certificatePinner()).isEmpty()); + } + + @Test + void constructor_with_null_caCerts_and_proxy_disables_pinning() throws Exception { + DuoConnector duoConnector = new DuoConnector(API_HOST, "proxy.example.com", 8080, null); + OkHttpClient client = getOkHttpClient(duoConnector); + assertTrue(getPins(client.certificatePinner()).isEmpty()); + } + + @Test + void constructor_with_caCerts_enables_pinning() throws Exception { + DuoConnector duoConnector = new DuoConnector(API_HOST, CA_CERT); + OkHttpClient client = getOkHttpClient(duoConnector); + assertFalse(getPins(client.certificatePinner()).isEmpty()); + } + + private OkHttpClient getOkHttpClient(DuoConnector connector) throws Exception { + Field retrofitField = DuoConnector.class.getDeclaredField("retrofit"); + retrofitField.setAccessible(true); + Retrofit retrofit = (Retrofit) retrofitField.get(connector); + Field clientField = Retrofit.class.getDeclaredField("callFactory"); + clientField.setAccessible(true); + return (OkHttpClient) clientField.get(retrofit); + } + + @SuppressWarnings("unchecked") + private java.util.Set getPins(CertificatePinner pinner) throws Exception { + Field pinsField = CertificatePinner.class.getDeclaredField("pins"); + pinsField.setAccessible(true); + return (java.util.Set) pinsField.get(pinner); + } + @Test void exchangeAuthorizationCodeFor2FAResult() throws IOException, DuoException { DuoConnector duoConnector = new DuoConnector(API_HOST, CA_CERT);