From c90fdbc93c92d4b36968db1c4ea11729386b346e Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Tue, 26 Aug 2025 18:04:52 +0200 Subject: [PATCH] Add SPKI pinning TLS decorator Introduce SpkiPinningClientTlsStrategy enforcing sha256(SPKI) pins post-handshake with exact/wildcard host matching. Opt-in; standard PKI and hostname verification remain in place. --- .../ssl/SpkiPinningClientTlsStrategy.java | 340 ++++++++++++ .../examples/ClientSpkiPinningExample.java | 83 +++ .../ssl/SpkiPinningClientTlsStrategyTest.java | 491 ++++++++++++++++++ 3 files changed, 914 insertions(+) create mode 100644 httpclient5/src/main/java/org/apache/hc/client5/http/ssl/SpkiPinningClientTlsStrategy.java create mode 100644 httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClientSpkiPinningExample.java create mode 100644 httpclient5/src/test/java/org/apache/hc/client5/http/ssl/SpkiPinningClientTlsStrategyTest.java 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 "*.