From 7e8641df1e16db9a52a6370f2ba5a3349d88fd5f Mon Sep 17 00:00:00 2001 From: Aaron McConnell Date: Tue, 23 Jun 2026 11:44:54 -0400 Subject: [PATCH] Add builder option to disable CA certificate pinning Allows callers to opt out of CA pinning while keeping TLS verification active via the OS trust store. This is a safety valve for customers who cannot upgrade when a cert chain change breaks pinning. Co-Authored-By: Claude Opus 4.6 --- .../java/com/duosecurity/client/Http.java | 31 +++++++++ .../client/HttpDisableCaPinningTest.java | 63 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 duo-client/src/test/java/com/duosecurity/client/HttpDisableCaPinningTest.java diff --git a/duo-client/src/main/java/com/duosecurity/client/Http.java b/duo-client/src/main/java/com/duosecurity/client/Http.java index abee6a2..ac169b9 100644 --- a/duo-client/src/main/java/com/duosecurity/client/Http.java +++ b/duo-client/src/main/java/com/duosecurity/client/Http.java @@ -438,6 +438,16 @@ public void useCustomCertificates(String[] customCaCerts) { httpClient = httpClient.newBuilder().certificatePinner(pinner).build(); } + /** + * Disable CA certificate pinning. TLS verification remains active + * via the OS trust store. + */ + public void disableCaPinning() { + httpClient = httpClient.newBuilder() + .certificatePinner(CertificatePinner.DEFAULT) + .build(); + } + protected String canonRequest(String date, int sigVersion) throws UnsupportedEncodingException { String canon = ""; @@ -543,6 +553,7 @@ protected abstract static class ClientBuilder { private int timeout = DEFAULT_TIMEOUT_SECS; private long maxBackoffMs = MAX_BACKOFF_MS; private String[] caCerts = null; + private boolean disableCaPinning = false; private SortedMap additionalDuoHeaders = new TreeMap(); private Map headers = new HashMap(); @@ -609,6 +620,19 @@ public ClientBuilder useCustomCertificates(String[] customCaCerts) { return this; } + /** + * Disable CA certificate pinning. TLS verification remains active + * via the OS trust store. + * + * @return the Builder + * @throws IllegalStateException if custom certificates have also been set + */ + public ClientBuilder disableCaPinning() { + this.disableCaPinning = true; + + return this; + } + /** * Set additional x-duo header for the HTTP client. * @@ -642,11 +666,18 @@ public ClientBuilder addHeader(String name, String value) { * @return the specified Http client object */ public T build() { + if (disableCaPinning && caCerts != null) { + throw new IllegalStateException( + "Cannot both disable CA pinning and provide custom certificates"); + } T duoClient = createClient(method, host, uri, timeout); duoClient.setMaxBackoffMs(maxBackoffMs); if (caCerts != null) { duoClient.useCustomCertificates(caCerts); } + if (disableCaPinning) { + duoClient.disableCaPinning(); + } if (additionalDuoHeaders != null) { duoClient.addAdditionalDuoHeader(additionalDuoHeaders); } diff --git a/duo-client/src/test/java/com/duosecurity/client/HttpDisableCaPinningTest.java b/duo-client/src/test/java/com/duosecurity/client/HttpDisableCaPinningTest.java new file mode 100644 index 0000000..5f13085 --- /dev/null +++ b/duo-client/src/test/java/com/duosecurity/client/HttpDisableCaPinningTest.java @@ -0,0 +1,63 @@ +package com.duosecurity.client; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.lang.reflect.Field; +import java.util.Set; +import okhttp3.CertificatePinner; +import okhttp3.OkHttpClient; +import org.junit.Test; + +public class HttpDisableCaPinningTest { + + private OkHttpClient getHttpClient(Http http) throws Exception { + Field httpClientField = Http.class.getDeclaredField("httpClient"); + httpClientField.setAccessible(true); + return (OkHttpClient) httpClientField.get(http); + } + + @SuppressWarnings("unchecked") + private Set getPins(CertificatePinner pinner) throws Exception { + Field pinsField = CertificatePinner.class.getDeclaredField("pins"); + pinsField.setAccessible(true); + return (Set) pinsField.get(pinner); + } + + @Test + public void testDisableCaPinning_removesPin() throws Exception { + Http http = new Http.HttpBuilder("GET", "api-host.duosecurity.com", "/auth/v2/check") + .disableCaPinning() + .build(); + + OkHttpClient client = getHttpClient(http); + Set pins = getPins(client.certificatePinner()); + assertTrue("Pins should be empty when CA pinning is disabled", pins.isEmpty()); + } + + @Test + public void testDefaultBuilder_hasPinning() throws Exception { + Http http = new Http.HttpBuilder("GET", "api-host.duosecurity.com", "/auth/v2/check") + .build(); + + OkHttpClient client = getHttpClient(http); + Set pins = getPins(client.certificatePinner()); + assertFalse("Pins should not be empty by default", pins.isEmpty()); + } + + @Test(expected = IllegalStateException.class) + public void testDisableAndCustomCerts_throws() { + new Http.HttpBuilder("GET", "api-host.duosecurity.com", "/auth/v2/check") + .disableCaPinning() + .useCustomCertificates(new String[]{"sha256/test"}) + .build(); + } + + @Test(expected = IllegalStateException.class) + public void testCustomCertsAndDisable_throws() { + new Http.HttpBuilder("GET", "api-host.duosecurity.com", "/auth/v2/check") + .useCustomCertificates(new String[]{"sha256/test"}) + .disableCaPinning() + .build(); + } +}