diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/ssl/SpkiPinningClientTlsStrategy.java b/httpclient5/src/main/java/org/apache/hc/client5/http/ssl/SpkiPinningClientTlsStrategy.java
new file mode 100644
index 0000000000..99c91a1311
--- /dev/null
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/ssl/SpkiPinningClientTlsStrategy.java
@@ -0,0 +1,340 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+
+package org.apache.hc.client5.http.ssl;
+
+import java.net.IDN;
+import java.security.MessageDigest;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Set;
+
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLException;
+import javax.net.ssl.SSLSession;
+
+import org.apache.hc.core5.annotation.Contract;
+import org.apache.hc.core5.annotation.ThreadingBehavior;
+
+/**
+ *
SPKI pinning decorator for client-side TLS.
+ *
+ * This strategy enforces one or more {@code sha256/} pins for a given
+ * host or single-label wildcard (e.g. {@code *.example.com}) after the standard
+ * trust manager and hostname verification succeed. Pins are matched against the
+ * {@code SubjectPublicKeyInfo} (SPKI) of any certificate in the peer chain.
+ *
+ * Host matching is performed on the IDNA ASCII (Punycode) lowercase form.
+ * Wildcards are single-label only (e.g. {@code *.example.com} matches
+ * {@code a.example.com} but not {@code a.b.example.com}).
+ *
+ * Warning: Certificate pinning increases operational risk.
+ * Always ship at least two pins (active + backup) and keep
+ * normal PKI + hostname verification enabled.
+ *
+ * Thread-safety: immutable and thread-safe.
+ *
+ * @since 5.6
+ */
+@Contract(threading = ThreadingBehavior.IMMUTABLE)
+public final class SpkiPinningClientTlsStrategy extends DefaultClientTlsStrategy {
+
+ private static final String PIN_PREFIX = "sha256/";
+ private static final int SHA256_LEN = 32;
+
+ /**
+ * Byte-array key with constant-time equality for use in sets/maps.
+ */
+ private static final class ByteArrayKey {
+ final byte[] v;
+ private final int hash;
+
+ ByteArrayKey(final byte[] v) {
+ this.v = Objects.requireNonNull(v, "bytes");
+ int h = 1;
+ for (int i = 0; i < v.length; i++) {
+ h = 31 * h + (v[i] & 0xff);
+ }
+ this.hash = h;
+ }
+
+ @Override
+ public boolean equals(final Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof ByteArrayKey)) {
+ return false;
+ }
+ return MessageDigest.isEqual(v, ((ByteArrayKey) o).v);
+ }
+
+ @Override
+ public int hashCode() {
+ return hash;
+ }
+ }
+
+ /**
+ * Match rule for a host or single-label wildcard.
+ */
+ private static final class Rule {
+ final String pattern; // normalized: IDNA ASCII + lowercase
+ final boolean wildcard; // true if pattern starts with "*."
+ final String tail; // for wildcard, ".example.com"; otherwise null
+ final Set pins; // unmodifiable set of 32-byte SHA-256 hashes
+
+ Rule(final String pattern, final Set pins) {
+ if (pattern == null) {
+ throw new IllegalArgumentException("Host pattern must not be null");
+ }
+ final String norm;
+ try {
+ norm = IDN.toASCII(pattern).toLowerCase(Locale.ROOT);
+ } catch (final IllegalArgumentException e) {
+ throw new IllegalArgumentException("Invalid IDN host pattern: " + pattern, e);
+ }
+ if (norm.isEmpty()) {
+ throw new IllegalArgumentException("Empty host pattern");
+ }
+ final boolean wc = norm.startsWith("*.");
+ if (wc && norm.indexOf('.', 2) < 0) { // require "*."
+ throw new IllegalArgumentException("Wildcard must be single-label: *.example.com");
+ }
+ if (pins == null || pins.isEmpty()) {
+ throw new IllegalArgumentException("At least one SPKI pin is required for " + pattern);
+ }
+ this.pattern = norm;
+ this.wildcard = wc;
+ this.tail = wc ? norm.substring(1) : null; // ".example.com"
+ this.pins = Collections.unmodifiableSet(new HashSet<>(pins));
+ }
+
+ // In Rule
+ boolean matches(final String host) {
+ if (host == null || host.isEmpty()) {
+ return false;
+ }
+ if (wildcard) {
+ if (!host.endsWith(tail)) {
+ return false;
+ }
+ final int boundary = host.length() - tail.length();
+ if (boundary < 1) {
+ return false;
+ }
+ if (host.charAt(boundary) != '.') {
+ return false;
+ }
+ return host.indexOf('.', 0) == boundary;
+ }
+ return host.equals(pattern);
+ }
+
+ }
+
+ private final List rules;
+
+ private SpkiPinningClientTlsStrategy(final SSLContext sslContext, final List rules) {
+ super(sslContext);
+ this.rules = Collections.unmodifiableList(new ArrayList<>(rules));
+ }
+
+ /**
+ * Invoked after the default trust and hostname checks. If one or more rules match the
+ * {@code hostname}, at least one pin must match any SPKI in the peer chain.
+ */
+ @Override
+ protected void verifySession(final String hostname, final SSLSession sslSession) throws SSLException {
+ final String host;
+ try {
+ // Canonicalize host: IDNA (Punycode) + lowercase for consistent matching.
+ host = IDN.toASCII(hostname == null ? "" : hostname).toLowerCase(Locale.ROOT);
+ } catch (final IllegalArgumentException e) {
+ throw new SSLException("Invalid IDN host: " + hostname, e);
+ }
+ super.verifySession(host, sslSession);
+ enforcePins(host, sslSession);
+ }
+
+ /**
+ * Enforce SPKI pins for the given hostname and session.
+ * Package-private for testing.
+ */
+ void enforcePins(final String hostname, final SSLSession sslSession) throws SSLException {
+ final List matched = matchedRules(hostname);
+ if (matched.isEmpty()) {
+ return; // No pins configured for this host.
+ }
+
+ final byte[][] peerSpkiHashes = chainSpkiSha256(sslSession);
+ for (int i = 0; i < peerSpkiHashes.length; i++) {
+ final ByteArrayKey key = new ByteArrayKey(peerSpkiHashes[i]);
+ for (int r = 0; r < matched.size(); r++) {
+ if (matched.get(r).pins.contains(key)) {
+ return; // match found
+ }
+ }
+ }
+
+ throw new SSLException("SPKI pinning failure for " + hostname
+ + "; peer pins: " + peerPinsForLog(peerSpkiHashes)
+ + "; configured pins: " + configuredPinsFor(matched));
+ }
+
+
+ /**
+ * Create a new builder.
+ *
+ * @param sslContext SSL context used for handshakes (trust + keys).
+ * @return builder
+ */
+ public static Builder newBuilder(final SSLContext sslContext) {
+ return new Builder(sslContext);
+ }
+
+ /**
+ * Builder for {@link SpkiPinningClientTlsStrategy}.
+ */
+ public static final class Builder {
+ private final SSLContext sslContext;
+ private final List rules = new ArrayList<>();
+
+ private Builder(final SSLContext sslContext) {
+ this.sslContext = Objects.requireNonNull(sslContext, "sslContext");
+ }
+
+ /**
+ * Add pins for a host pattern.
+ *
+ * @param hostPattern exact host (e.g. {@code api.example.com}) or single-label wildcard
+ * (e.g. {@code *.example.com}).
+ * @param pins one or more pins in the form {@code sha256/BASE64}.
+ * @return this
+ * @throws IllegalArgumentException if a pin is not {@code sha256/...}, has invalid Base64, or wrong length.
+ */
+ public Builder add(final String hostPattern, final String... pins) {
+ if (pins == null || pins.length == 0) {
+ throw new IllegalArgumentException("No pins supplied for " + hostPattern);
+ }
+ final Set set = new HashSet<>(pins.length);
+ for (int i = 0; i < pins.length; i++) {
+ set.add(parsePin(pins[i]));
+ }
+ rules.add(new Rule(hostPattern, set));
+ return this;
+ }
+
+ /**
+ * Build an immutable {@link SpkiPinningClientTlsStrategy}.
+ */
+ public SpkiPinningClientTlsStrategy build() {
+ return new SpkiPinningClientTlsStrategy(sslContext, rules);
+ }
+
+ private static ByteArrayKey parsePin(final String s) {
+ if (s == null) {
+ throw new IllegalArgumentException("Pin must not be null");
+ }
+ final String t = s.trim();
+ if (!t.regionMatches(true, 0, PIN_PREFIX, 0, PIN_PREFIX.length())) {
+ throw new IllegalArgumentException("Only sha256 pins are supported: " + s);
+ }
+ final String b64 = t.substring(PIN_PREFIX.length()).trim();
+ final byte[] raw;
+ try {
+ raw = Base64.getDecoder().decode(b64);
+ } catch (final IllegalArgumentException e) {
+ throw new IllegalArgumentException("Invalid Base64 in SPKI pin: " + s, e);
+ }
+ if (raw.length != SHA256_LEN) {
+ throw new IllegalArgumentException("SPKI pin must be 32 bytes (SHA-256): " + s);
+ }
+ return new ByteArrayKey(raw);
+ }
+ }
+
+
+ private List matchedRules(final String host) {
+ final List out = new ArrayList<>();
+ for (int i = 0; i < rules.size(); i++) {
+ final Rule r = rules.get(i);
+ if (r.matches(host)) {
+ out.add(r);
+ }
+ }
+ return out;
+ }
+
+ private static byte[][] chainSpkiSha256(final SSLSession session) throws SSLException {
+ final Certificate[] chain = session.getPeerCertificates();
+ try {
+ final MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
+ final List out = new ArrayList<>(chain.length);
+ for (int i = 0; i < chain.length; i++) {
+ final Certificate c = chain[i];
+ if (c instanceof X509Certificate) {
+ final byte[] spki = ((X509Certificate) c).getPublicKey().getEncoded();
+ out.add(sha256.digest(spki));
+ }
+ }
+ if (out.isEmpty()) {
+ throw new SSLException("No X509Certificate in peer chain");
+ }
+ return out.toArray(new byte[out.size()][]);
+ } catch (final SSLException e) {
+ throw e;
+ } catch (final Exception e) {
+ throw new SSLException("Cannot compute SPKI sha256", e);
+ }
+ }
+
+ private static String configuredPinsFor(final List rules) {
+ final List pins = new ArrayList<>();
+ for (int i = 0; i < rules.size(); i++) {
+ for (final ByteArrayKey k : rules.get(i).pins) {
+ pins.add(PIN_PREFIX + Base64.getEncoder().encodeToString(k.v));
+ }
+ }
+ return pins.toString();
+ }
+
+ private static String peerPinsForLog(final byte[][] hashes) {
+ final List pins = new ArrayList<>(hashes.length);
+ for (int i = 0; i < hashes.length; i++) {
+ pins.add(PIN_PREFIX + Base64.getEncoder().encodeToString(hashes[i]));
+ }
+ return pins.toString();
+ }
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClientSpkiPinningExample.java b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClientSpkiPinningExample.java
new file mode 100644
index 0000000000..a05c4b2367
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClientSpkiPinningExample.java
@@ -0,0 +1,83 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+
+package org.apache.hc.client5.http.examples;
+
+import javax.net.ssl.SSLContext;
+
+import org.apache.hc.client5.http.classic.methods.HttpGet;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.client5.http.impl.classic.HttpClients;
+import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
+import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
+import org.apache.hc.client5.http.ssl.SpkiPinningClientTlsStrategy;
+import org.apache.hc.core5.http.io.entity.EntityUtils;
+import org.apache.hc.core5.http.message.StatusLine;
+import org.apache.hc.core5.ssl.SSLContexts;
+
+/**
+ * Example: using SPKI pinning with the classic (blocking) client.
+ *
+ * Warning: Replace the pins with real values for your hosts and always ship a
+ * backup pin. Keep PKI + hostname verification enabled.
+ */
+public class ClientSpkiPinningExample {
+
+ public static void main(final String[] args) throws Exception {
+ final SSLContext sslContext = SSLContexts.createSystemDefault();
+
+ final SpkiPinningClientTlsStrategy pinning = SpkiPinningClientTlsStrategy
+ .newBuilder(sslContext)
+ // Replace with real host(s) and real pins (sha256/)
+ .add("example.com",
+ "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+ "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") // backup
+ .add("*.example.net",
+ "sha256/CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=")
+ .build();
+
+ final PoolingHttpClientConnectionManager cm =
+ PoolingHttpClientConnectionManagerBuilder.create()
+ .setTlsSocketStrategy(pinning) // classic path
+ .build();
+
+ try (final CloseableHttpClient httpclient = HttpClients.custom()
+ .setConnectionManager(cm)
+ .build()) {
+
+ final HttpGet httpget = new HttpGet("https://example.com/");
+ System.out.println("Executing: " + httpget.getMethod() + " " + httpget.getUri());
+
+ httpclient.execute(httpget, response -> {
+ System.out.println("----------------------------------------");
+ System.out.println(httpget + " -> " + new StatusLine(response));
+ EntityUtils.consume(response.getEntity());
+ return null;
+ });
+ }
+ }
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/ssl/SpkiPinningClientTlsStrategyTest.java b/httpclient5/src/test/java/org/apache/hc/client5/http/ssl/SpkiPinningClientTlsStrategyTest.java
new file mode 100644
index 0000000000..cf83fca9d1
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/ssl/SpkiPinningClientTlsStrategyTest.java
@@ -0,0 +1,491 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+
+package org.apache.hc.client5.http.ssl;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.math.BigInteger;
+import java.net.IDN;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.Principal;
+import java.security.PublicKey;
+import java.security.cert.CertificateEncodingException;
+import java.security.cert.X509Certificate;
+import java.util.Base64;
+import java.util.Date;
+import java.util.Locale;
+import java.util.Set;
+
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLException;
+import javax.net.ssl.SSLSession;
+import javax.security.auth.x500.X500Principal;
+
+import org.junit.jupiter.api.Test;
+
+class SpkiPinningClientTlsStrategyTest {
+
+ private static String sha256Pin(final byte[] spki) throws Exception {
+ final byte[] digest = MessageDigest.getInstance("SHA-256").digest(spki);
+ return "sha256/" + Base64.getEncoder().encodeToString(digest);
+ }
+
+ @Test
+ void exactHostMatch() throws Exception {
+ final byte[] spki = new byte[]{1, 2, 3, 4, 5};
+ final String pin = sha256Pin(spki);
+
+ final SpkiPinningClientTlsStrategy strategy = SpkiPinningClientTlsStrategy
+ .newBuilder(SSLContext.getDefault())
+ .add("api.example.com", pin)
+ .build();
+
+ final SSLSession session = new FakeSession(new X509WithKey(spki));
+ assertDoesNotThrow(() -> strategy.enforcePins("api.example.com", session));
+ }
+
+ @Test
+ void wildcardMatch() throws Exception {
+ final byte[] spki = new byte[]{9, 9, 9, 9};
+ final String pin = sha256Pin(spki);
+
+ final SpkiPinningClientTlsStrategy strategy = SpkiPinningClientTlsStrategy
+ .newBuilder(SSLContext.getDefault())
+ .add("*.example.com", pin)
+ .build();
+
+ final SSLSession session = new FakeSession(new X509WithKey(spki));
+ assertDoesNotThrow(() -> strategy.enforcePins("svc.example.com", session));
+ }
+
+ @Test
+ void pinningFailure() throws Exception {
+ final byte[] spki = new byte[]{7, 7, 7};
+ final String wrongPin = sha256Pin(new byte[]{8, 8, 8});
+
+ final SpkiPinningClientTlsStrategy strategy = SpkiPinningClientTlsStrategy
+ .newBuilder(SSLContext.getDefault())
+ .add("api.example.com", wrongPin)
+ .build();
+
+ final SSLSession session = new FakeSession(new X509WithKey(spki));
+ assertThrows(SSLException.class, () -> strategy.enforcePins("api.example.com", session));
+ }
+
+ @Test
+ void wildcardDoesNotMatchMultiLabel() throws Exception {
+ final byte[] spki = new byte[]{4, 2, 4, 2};
+ final String pin = sha256Pin(spki);
+
+ final SpkiPinningClientTlsStrategy strategy = SpkiPinningClientTlsStrategy
+ .newBuilder(SSLContext.getDefault())
+ .add("*.example.com", pin)
+ .build();
+
+ // a.b.example.com should NOT match single-label wildcard -> no pinning enforced -> no throw
+ final SSLSession session = new FakeSession(new X509WithKey(new byte[]{1, 2, 3}));
+ assertDoesNotThrow(() -> strategy.enforcePins("a.b.example.com", session));
+ }
+
+ @Test
+ void backupPinSucceedsWhenFirstPinDoesNotMatch() throws Exception {
+ final byte[] spkiGood = new byte[]{10, 11, 12, 13};
+ final byte[] spkiBad = new byte[]{99, 99, 99, 99};
+ final String wrongPin = sha256Pin(spkiBad);
+ final String goodPin = sha256Pin(spkiGood);
+
+ final SpkiPinningClientTlsStrategy strategy = SpkiPinningClientTlsStrategy
+ .newBuilder(SSLContext.getDefault())
+ // wrong pin first, correct pin second
+ .add("api.example.com", wrongPin, goodPin)
+ .build();
+
+ final SSLSession session = new FakeSession(new X509WithKey(spkiGood));
+ assertDoesNotThrow(() -> strategy.enforcePins("api.example.com", session));
+ }
+
+ @Test
+ void idnExactHostMatch() throws Exception {
+ // Host: bücher.example -> xn--bcher-kva.example
+ final byte[] spki = new byte[]{42, 42, 42, 42};
+ final String pin = sha256Pin(spki);
+
+ final SpkiPinningClientTlsStrategy strategy = SpkiPinningClientTlsStrategy
+ .newBuilder(SSLContext.getDefault())
+ .add("bücher.example", pin)
+ .build();
+
+ final SSLSession session = new FakeSession(new X509WithKey(spki));
+ // enforcePins expects IDNA ASCII (like verifySession would pass)
+ final String ascii = IDN.toASCII("bücher.example").toLowerCase(Locale.ROOT);
+ assertDoesNotThrow(() -> strategy.enforcePins(ascii, session));
+ }
+
+ @Test
+ void invalidBase64PinRejected() {
+ assertThrows(IllegalArgumentException.class, () -> SpkiPinningClientTlsStrategy
+ .newBuilder(SSLContext.getDefault())
+ .add("api.example.com", "sha256/###not_base64###"));
+ }
+
+ @Test
+ void wrongLengthPinRejected() {
+ // Base64 of 1 byte -> decoded length != 32
+ final String shortPin = "sha256/" + Base64.getEncoder().encodeToString(new byte[]{1});
+ assertThrows(IllegalArgumentException.class, () -> SpkiPinningClientTlsStrategy
+ .newBuilder(SSLContext.getDefault())
+ .add("api.example.com", shortPin));
+ }
+
+ @Test
+ void emptyPinsRejected() {
+ assertThrows(IllegalArgumentException.class, () -> SpkiPinningClientTlsStrategy
+ .newBuilder(SSLContext.getDefault())
+ .add("api.example.com"));
+ }
+
+ @Test
+ void invalidWildcardPatternRejected() {
+ // "*.": not a valid single-label wildcard
+ assertThrows(IllegalArgumentException.class, () -> SpkiPinningClientTlsStrategy
+ .newBuilder(SSLContext.getDefault())
+ .add("*.", "sha256/" + Base64.getEncoder().encodeToString(new byte[32])));
+ }
+
+ @Test
+ void wildcardConfiguredButWrongPinFails() throws Exception {
+ final byte[] spki = new byte[]{5, 5, 5, 5};
+ final String wrongPin = sha256Pin(new byte[]{6, 6, 6, 6});
+
+ final SpkiPinningClientTlsStrategy strategy = SpkiPinningClientTlsStrategy
+ .newBuilder(SSLContext.getDefault())
+ .add("*.example.com", wrongPin)
+ .build();
+
+ final SSLSession session = new FakeSession(new X509WithKey(spki));
+ assertThrows(SSLException.class, () -> strategy.enforcePins("svc.example.com", session));
+ }
+
+ @Test
+ void noConfiguredPinsForHostShortCircuits() throws Exception {
+ // Pins configured for other domain, not for foo.bar
+ final byte[] spki = new byte[]{1, 1, 1, 1};
+ final String pin = sha256Pin(spki);
+
+ final SpkiPinningClientTlsStrategy strategy = SpkiPinningClientTlsStrategy
+ .newBuilder(SSLContext.getDefault())
+ .add("api.example.com", pin)
+ .build();
+
+ final SSLSession session = new FakeSession(new X509WithKey(new byte[]{9, 9, 9}));
+ // No rules match -> pinning not enforced -> no throw
+ assertDoesNotThrow(() -> strategy.enforcePins("foo.bar", session));
+ }
+
+ @Test
+ void verifySessionInvalidIdnThrowsSslException() throws NoSuchAlgorithmException {
+ final SpkiPinningClientTlsStrategy s = SpkiPinningClientTlsStrategy
+ .newBuilder(SSLContext.getDefault())
+ .add("api.example.com", "sha256/" + Base64.getEncoder().encodeToString(new byte[32]))
+ .build();
+ final SSLSession session = new FakeSession(new X509WithKey(new byte[]{1}));
+ assertThrows(SSLException.class, () -> s.verifySession("\uDC00bad", session));
+ }
+
+
+ private static final class X509WithKey extends X509Certificate {
+ private final PublicKey key;
+
+ X509WithKey(final byte[] spki) {
+ this.key = new PublicKey() {
+ @Override
+ public String getAlgorithm() {
+ return "RSA";
+ }
+
+ @Override
+ public String getFormat() {
+ return "X.509";
+ }
+
+ @Override
+ public byte[] getEncoded() {
+ return spki;
+ }
+ };
+ }
+
+ @Override
+ public PublicKey getPublicKey() {
+ return key;
+ }
+
+ @Override
+ public void checkValidity() {
+ }
+
+ @Override
+ public void checkValidity(final Date date) {
+ }
+
+ @Override
+ public int getVersion() {
+ return 3;
+ }
+
+ @Override
+ public BigInteger getSerialNumber() {
+ return BigInteger.ONE;
+ }
+
+ @Override
+ public Principal getIssuerDN() {
+ return new X500Principal("CN=issuer");
+ }
+
+ @Override
+ public Principal getSubjectDN() {
+ return new X500Principal("CN=subject");
+ }
+
+ @Override
+ public Date getNotBefore() {
+ return new Date(0L);
+ }
+
+ @Override
+ public Date getNotAfter() {
+ return new Date(4102444800000L);
+ } // ~2100-01-01
+
+ @Override
+ public byte[] getTBSCertificate() throws CertificateEncodingException {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public byte[] getSignature() {
+ return new byte[0];
+ }
+
+ @Override
+ public String getSigAlgName() {
+ return "NONE";
+ }
+
+ @Override
+ public String getSigAlgOID() {
+ return "0.0";
+ }
+
+ @Override
+ public byte[] getSigAlgParams() {
+ return null;
+ }
+
+ @Override
+ public boolean[] getIssuerUniqueID() {
+ return null;
+ }
+
+ @Override
+ public boolean[] getSubjectUniqueID() {
+ return null;
+ }
+
+ @Override
+ public boolean[] getKeyUsage() {
+ return null;
+ }
+
+ @Override
+ public int getBasicConstraints() {
+ return -1;
+ }
+
+ @Override
+ public byte[] getEncoded() throws CertificateEncodingException {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void verify(final PublicKey key) {
+ }
+
+ @Override
+ public void verify(final PublicKey key, final String sigProvider) {
+ }
+
+ @Override
+ public String toString() {
+ return "X509WithKey";
+ }
+
+ @Override
+ public X500Principal getIssuerX500Principal() {
+ return new X500Principal("CN=issuer");
+ }
+
+ @Override
+ public X500Principal getSubjectX500Principal() {
+ return new X500Principal("CN=subject");
+ }
+
+ @Override
+ public Set getCriticalExtensionOIDs() {
+ return null;
+ }
+
+ @Override
+ public Set getNonCriticalExtensionOIDs() {
+ return null;
+ }
+
+ @Override
+ public byte[] getExtensionValue(final String oid) {
+ return null;
+ }
+
+ @Override
+ public boolean hasUnsupportedCriticalExtension() {
+ return false;
+ }
+ }
+
+ private static final class FakeSession implements SSLSession {
+ private final X509Certificate[] chain;
+
+ FakeSession(final X509Certificate cert) {
+ this.chain = new X509Certificate[]{cert};
+ }
+
+ @Override
+ public java.security.cert.Certificate[] getPeerCertificates() {
+ return chain;
+ }
+
+ @Override
+ public javax.security.cert.X509Certificate[] getPeerCertificateChain() {
+ return new javax.security.cert.X509Certificate[0];
+ }
+
+ @Override
+ public String getProtocol() {
+ return "TLSv1.3";
+ }
+
+ @Override
+ public String getCipherSuite() {
+ return "TLS_AES_128_GCM_SHA256";
+ }
+
+ @Override
+ public Principal getPeerPrincipal() {
+ return null;
+ }
+
+ @Override
+ public Principal getLocalPrincipal() {
+ return null;
+ }
+
+ @Override
+ public java.security.cert.Certificate[] getLocalCertificates() {
+ return new java.security.cert.Certificate[0];
+ }
+
+ @Override
+ public String getPeerHost() {
+ return "api.example.com";
+ }
+
+ @Override
+ public int getPeerPort() {
+ return 443;
+ }
+
+ @Override
+ public int getPacketBufferSize() {
+ return 0;
+ }
+
+ @Override
+ public int getApplicationBufferSize() {
+ return 0;
+ }
+
+ @Override
+ public long getCreationTime() {
+ return 0;
+ }
+
+ @Override
+ public long getLastAccessedTime() {
+ return 0;
+ }
+
+ @Override
+ public void invalidate() {
+ }
+
+ @Override
+ public boolean isValid() {
+ return true;
+ }
+
+ @Override
+ public Object getValue(final String s) {
+ return null;
+ }
+
+ @Override
+ public String[] getValueNames() {
+ return new String[0];
+ }
+
+ @Override
+ public void putValue(final String s, final Object o) {
+ }
+
+ @Override
+ public void removeValue(final String s) {
+ }
+
+ @Override
+ public javax.net.ssl.SSLSessionContext getSessionContext() {
+ return null;
+ }
+
+ @Override
+ public byte[] getId() {
+ return new byte[0];
+ }
+ }
+}