Перейти к содержимому
ecdsa.com
← Все статьи

Закрепление сертификатов в 2026: когда и как безопасно

Закрепление сертификатов (certificate pinning) жертвует гибкостью публичной PKI ради защиты от одного редкого сбоя: доверенный центр сертификации (CA) выпускает сертификат для вашего домена кому-то другому. Ниже показано, во что обходится этот размен в 2026 году: пины вычислены для настоящей цепочки и проверены в OkHttp 5.5.0 и в App Transport Security от Apple на macOS 27.

Коротко. Браузеры отказались от закрепления через HTTP-заголовок (HPKP): Chrome убрал его в версии 72 (январь 2019 года), Firefox — в версии 72 (январь 2020 года), а большую часть того, что HPKP должен был выявлять, теперь покрывает Certificate Transparency. Закрепление сохранилось в мобильных приложениях и API-клиентах, которые обращаются к серверам, которыми вы управляете. Если вы закрепляете, закрепляйте хеш SHA-256 открытого ключа (SPKI), а не сертификата; закрепляйте ключи тех CA, которыми будете пользоваться и дальше (промежуточных или корневых), а не ключ конечного сертификата: большинство ACME-клиентов заменяют его при каждом продлении; всегда включайте в приложение хотя бы один резервный пин — для другого CA или для ключа, который хранится офлайн; используйте механизм платформы: в Android — конфигурацию сетевой безопасности (Network Security Configuration) с заданным сроком действия пинов, в iOS — NSPinnedDomains. И Google, и Apple не советуют закреплять, если у вас нет конкретной причины.

Что такое пин

Обычная проверка сертификата принимает любую цепочку, ведущую к любому корневому сертификату из хранилища доверенных сертификатов. Пин добавляет ещё одно условие: хотя бы один ключ в проверенной цепочке должен быть в вашем списке. Список содержит хеши структуры SubjectPublicKeyInfo (SPKI) — идентификатора алгоритма и открытого ключа в DER-кодировке. Поскольку хешируется ключ, а не сертификат, продлённый сертификат с тем же ключом по-прежнему совпадает с пином, а пин ключа CA совпадает с каждым сертификатом, который выпускает этот CA.

Пины вычисляют из сертификатов с помощью OpenSSL. Для цепочки, которую letsencrypt.org отдавал 20 октября 2026 года (eclipse-temurin:25.0.4.1_1-jdk-noble, OpenSSL 3.0.13):

Пин SPKI каждого сертификата, который отдаёт сервер
openssl s_client -connect letsencrypt.org:443 -servername letsencrypt.org -showcerts </dev/null 2>/dev/null \
  | awk '/-----BEGIN CERTIFICATE-----/{n++; f=sprintf("c%d.pem", n); p=1} p{print >> f} /-----END CERTIFICATE-----/{p=0; close(f)}'
pin() { openssl x509 -in "$1" -noout -pubkey | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64; }
for f in c*.pem; do printf '%s  %s\n' "$(pin "$f")" "$(openssl x509 -in "$f" -noout -subject | sed 's/^subject=//')"; done
Вывод
x8ltqh+zNPYG7RZS+IjFymndoy6YAWLPaSkptqebzfc=  CN = letsencrypt.org
s/tdAOmUzd8syaTuqfgGvFcn6DzA5Cmb+Vby1ST+U3Y=  C = US, O = Let's Encrypt, CN = YE2
sCkq5UWXjg+7mKu9lMhhYF5bGLsy7VI/UNW3tccdR7w=  C = US, O = ISRG, CN = Root YE
diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=  C = US, O = Internet Security Research Group, CN = ISRG Root X2

Со временем эти четыре кандидата ведут себя совершенно по-разному, и основная часть работы — выбрать среди них. Генератор пинов SPKI вычисляет те же значения прямо в браузере и выводит готовые фрагменты конфигурации.

Почему браузеры отказались от HPKP

HTTP Public Key Pinning (RFC 7469, 2015) позволял сайту отправить заголовок Public-Key-Pins с хешами ключей и max-age; после этого браузер в течение указанного срока отклонял любую цепочку, в которой не было ни одного из этих ключей. HPKP погубили два сценария отказа:

  • Сбои по собственной вине. Потерянный ключ, смена CA или опечатка закрывали доступ к сайту каждому вернувшемуся посетителю до истечения max-age, и отменить это со стороны сервера было невозможно.
  • Враждебное закрепление. Злоумышленник, ненадолго получивший контроль над сайтом (или над выпуском сертификатов для него), мог отправить пины ключей, которые были только у него, и превратить кратковременную компрометацию в долгий отказ в обслуживании.

В записи Chrome об удалении функции названы причины: «очень низкая распространённость» и «риски отказа в обслуживании и враждебного закрепления» (chromestatus); поддержку убрали в Chrome 72 для настольных систем и Android, а Firefox последовал за ним в версии 72. Браузеры по-прежнему поставляются с небольшим встроенным списком пинов для собственных доменов своих разработчиков, но ни один сайт не может добавить в него себя.

На смену HPKP пришло обнаружение, а не предотвращение. С 2018 года Chrome и платформы Apple принимают публично доверенные TLS-сертификаты только с подписанными метками времени сертификата (SCT) из журналов Certificate Transparency, поэтому каждый сертификат для вашего домена публичен и его можно отслеживать; записи CAA указывают, каким CA разрешено выпускать сертификаты для вашего домена. Вместе они делают ошибочный выпуск сертификатов, против которого был направлен HPKP, заметным в течение нескольких часов — без риска заблокировать пользователей.

Что закреплять

ПинЧто выдерживаетКогда ломается
Ключ конечного сертификатаПродления с сохранением ключаПри каждом продлении с новым ключом, а в certbot это поведение по умолчанию
Выпускающий промежуточный сертификат (YE2)Продления конечного сертификата и смену его ключаCA меняет промежуточные сертификаты: у Let's Encrypt их несколько в ротации и есть резервные, с которых он «может начать выпуск … в любой момент, без предупреждения»
Корневой сертификат (ISRG Root X2)Ротацию промежуточных сертификатовВы меняете CA, или CA лишают доверия
Ключ, который вы храните офлайнВсё что угодно, пока он не используетсяТолько если вы его потеряете

Строку о конечном сертификате стоит выделить: именно в эту ловушку попадают по умолчанию. certbot --help all (v5.8.0) описывает --reuse-key как параметр, выключенный по умолчанию, и сообщает: «Not reusing private keys is the default behavior of Certbot» (по умолчанию Certbot не использует закрытые ключи повторно). Поэтому пин конечного сертификата ломается при следующем продлении, а продления становятся всё чаще: CA/Browser Forum ограничивает срок действия TLS-сертификатов 200 днями с марта 2026 года, 100 днями с марта 2027 года и 47 днями с марта 2029 года, а Let's Encrypt по умолчанию перейдёт на 64 дня в феврале 2027 года.

Со строкой о промежуточном сертификате всё тоньше. Сертификат letsencrypt.org выпущен промежуточным CA YE2, но, по списку Let's Encrypt, в активной ротации находятся YE1 и YE2 (ECDSA) и YR1 и YR2 (RSA), а YE3 и YR3 — резервные (цепочки доверия). Набор пинов, в котором есть только YE2, перестанет работать, как только очередной сертификат будет выпущен промежуточным CA YE1.

Пин корневого сертификата выдерживает всё это и ломается, только когда вы меняете CA. Это случается чаще, чем принято думать: когда Chrome перестал доверять сертификатам Entrust, самая ранняя SCT которых датирована позднее 11 ноября 2024 года (Google Security Blog), всем клиентам Entrust пришлось за несколько месяцев перейти к другому CA. Приложение, закреплённое на корневых сертификатах Entrust без резервного пина, сначала пришлось бы обновить.

Обычный компромисс — закрепить корневой сертификат (или оба корневых) текущего CA, корневой сертификат второго CA, на который вы могли бы перейти, и, по желанию, ключ, который вы сгенерировали и храните офлайн для аварийного сертификата. Для Let's Encrypt в роли текущего CA и Google Trust Services в роли запасного, по пакету ca-certificates 20260816 из Debian:

Пины корневых сертификатов (тот же конвейер openssl для /etc/ssl/certs/*.pem)
C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=  subject=C=US, O=Internet Security Research Group, CN=ISRG Root X1 notAfter=Jun  4 11:04:38 2035 GMT
diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=  subject=C=US, O=Internet Security Research Group, CN=ISRG Root X2 notAfter=Sep 17 16:00:00 2040 GMT
mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c=  subject=C=US, O=Google Trust Services LLC, CN=GTS Root R4 notAfter=Jun 22 00:00:00 2036 GMT
hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc=  subject=C=US, O=Google Trust Services LLC, CN=GTS Root R1 notAfter=Jun 22 00:00:00 2036 GMT

Пин ISRG Root X2 совпадает с последним сертификатом, который отдаёт letsencrypt.org (X2 с перекрёстной подписью X1), потому что в нём тот же ключ, что и в самоподписанном X2 из хранилищ доверенных сертификатов. Закрепить корневой сертификат другого CA, которым вы пока не пользуетесь, ничего не стоит, и это ваш запасной выход.

Android: конфигурация сетевой безопасности

Android 7.0 и новее читает пины из файла res/xml/network_security_config.xml, на который ссылается атрибут android:networkSecurityConfig в манифесте (документация):

res/xml/network_security_config.xml: корневые сертификаты текущего CA и второго CA, со сроком действия
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config>
        <domain includeSubdomains="true">api.example.com</domain>
        <pin-set expiration="2027-10-01">
            <!-- ISRG Root X2 and X1: the current CA, ECDSA and RSA chains -->
            <pin digest="SHA-256">diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=</pin>
            <pin digest="SHA-256">C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=</pin>
            <!-- GTS Root R4 and R1: the CA we would switch to -->
            <pin digest="SHA-256">mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c=</pin>
            <pin digest="SHA-256">hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc=</pin>
        </pin-set>
    </domain-config>
</network-security-config>

Важные правила из документации:

  • Цепочка принимается, если она «содержит хотя бы один из закреплённых открытых ключей»; единственный поддерживаемый дайджест — SHA-256.
  • После даты expiration (yyyy-MM-dd) закрепление больше не применяется. Благодаря этому старые установки продолжают работать, даже когда вы о них забыли; документация также предупреждает, что это «может позволить злоумышленникам обойти закреплённые сертификаты» на устройствах с давно не обновлявшимися приложениями. Выбирайте дату примерно на один цикл релизов позже даты, когда вы в следующий раз обновите пины.
  • Якоря доверия в <debug-overrides> обходят пины, когда приложение собрано как отлаживаемое (debuggable): так просматривают трафик во время разработки; overridePins="true" в элементе <certificates> делает то же самое в релизных сборках, и выпускать приложение с этой настройкой не следует.
  • В Android 16 в тот же файл добавили проверки Certificate Transparency (<certificateTransparency enabled="true"/>), а начиная с Android 17 они включены по умолчанию для приложений, ориентированных на эту версию, кроме соединений с собственными якорями доверия. Многим приложениям это даёт ту самую защиту, которой они добивались закреплением.

В собственных рекомендациях Google сказано прямо: закрепление «не рекомендуется для приложений Android», а если вы всё же закрепляете, включите «несколько резервных пинов, в том числе хотя бы один ключ, который полностью под вашим контролем, и достаточно короткий срок действия» (безопасность сетевых протоколов).

OkHttp

CertificatePinner из OkHttp работает и в Android, и на JVM, где конфигурации сетевой безопасности нет. Тест сначала закрепляет letsencrypt.org на ISRG Root X2 с X1 в качестве резервного пина, а затем на ключе, не заверенном ни одним CA, — именно это видит приложение, когда сервер переходит к незакреплённому CA:

Pin.java (OkHttp 5.5.0, Okio 3.18.1, стандартная библиотека Kotlin 2.1.21, Java 25)
// OkHttp CertificatePinner against letsencrypt.org: a correct CA-key pin, a backup pin, and a wrong pin.
import java.io.IOException;
import okhttp3.CertificatePinner;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;

public class Pin {
    static void get(String label, CertificatePinner pinner) {
        OkHttpClient client = new OkHttpClient.Builder().certificatePinner(pinner).build();
        Request request = new Request.Builder().url("https://letsencrypt.org/robots.txt").build();
        try (Response response = client.newCall(request).execute()) {
            System.out.println(label + ": HTTP " + response.code());
        } catch (IOException e) {
            System.out.println(label + ": " + e.getClass().getName() + ": " + e.getMessage());
        }
    }

    public static void main(String[] args) {
        // ISRG Root X2 key, plus a backup pin for ISRG Root X1 (RSA): either satisfies the pin set.
        get("pinned to ISRG Root X2 (+ X1 backup)", new CertificatePinner.Builder()
            .add("letsencrypt.org", "sha256/" + args[0])
            .add("letsencrypt.org", "sha256/" + args[1])
            .build());
        // A key that is not in the chain: what a client sees after the site moves to another CA.
        get("pinned to an unrelated key", new CertificatePinner.Builder()
            .add("letsencrypt.org", "sha256/" + args[2])
            .build());
    }
}
Вывод команды java -Djava.net.preferIPv4Stack=true -cp okhttp-jvm-5.5.0.jar:okio-jvm-3.18.1.jar:kotlin-stdlib-2.1.21.jar Pin.java …
pinned to ISRG Root X2 (+ X1 backup): HTTP 200
pinned to an unrelated key: javax.net.ssl.SSLPeerUnverifiedException: Certificate pinning failure!
  Peer certificate chain:
    sha256/x8ltqh+zNPYG7RZS+IjFymndoy6YAWLPaSkptqebzfc=: CN=letsencrypt.org
    sha256/s/tdAOmUzd8syaTuqfgGvFcn6DzA5Cmb+Vby1ST+U3Y=: CN=YE2, O=Let's Encrypt, C=US
    sha256/sCkq5UWXjg+7mKu9lMhhYF5bGLsy7VI/UNW3tccdR7w=: CN=Root YE, O=ISRG, C=US
    sha256/diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=: CN=ISRG Root X2, O=Internet Security Research Group, C=US
  Pinned certificates for letsencrypt.org:
    sha256/VIJvW/e8yv/LvjcOADWb4C9aNclxEiQeW0tr4SvazsA=

Сообщение об ошибке перечисляет пины цепочки, которую проверил OkHttp, вплоть до якоря доверия, и собственная документация OkHttp предлагает именно такой способ настройки: начните с заведомо неверного пина в доверенной сети и скопируйте ожидаемые значения из исключения. Пины совпадают с выводом OpenSSL выше. В том же комментарии есть предупреждение, которое стоит повторить тому, кто утверждает изменение: «Do not use certificate pinning without the blessing of your server's TLS administrator!» (не используйте закрепление сертификатов без одобрения администратора TLS вашего сервера). Кроме того, CertificatePinner срабатывает только после обычной проверки, поэтому не может сделать самоподписанный сертификат допустимым.

Мы попали в ловушку при отладке: в контейнере не было маршрута IPv6, и без -Djava.net.preferIPv4Stack=true второй вызов выбрасывал исключение java.net.SocketException: Network is unreachable. OkHttp 5 пробует адреса параллельно («fast fallback»), и ошибка закрепления на адресе IPv4 оказывалась подавленным (suppressed) исключением за ошибкой подключения по IPv6; с fastFallback(false) ошибка закрепления становилась основным исключением. Если закрепление ломается в рабочей среде, записывайте в журнал и getSuppressed().

iOS и macOS: NSPinnedDomains

Начиная с iOS 14 и macOS 11 App Transport Security закрепляет ключи, объявленные в Info.plist. Статья Apple «Identity Pinning» описывает два списка для каждого домена: NSPinnedCAIdentities (ключ должен встречаться в промежуточном или корневом сертификате цепочки) и NSPinnedLeafIdentities (собственный ключ сервера); каждая запись — значение SPKI-SHA256-BASE64, вычисленное тем же конвейером OpenSSL:

Info.plist (фрагмент): закрепление letsencrypt.org на ключе ISRG Root X2
<key>NSAppTransportSecurity</key>
<dict>
    <key>NSPinnedDomains</key>
    <dict>
        <key>letsencrypt.org</key>
        <dict>
            <key>NSPinnedCAIdentities</key>
            <array>
                <dict>
                    <key>SPKI-SHA256-BASE64</key>
                    <string>diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=</string>
                </dict>
            </array>
        </dict>
    </dict>
</dict>

Мы проверили поведение на нашей машине (macOS 27.0.1, Apple M4 Max, Swift 6.4): утилиту командной строки, которая загружает https://letsencrypt.org/robots.txt через URLSession, собрали трижды командой swiftc main.swift -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker Info.plist — без пинов, с пином X2 из примера выше и с посторонним ключом из теста OkHttp:

Вывод трёх сборок
HTTP 200
HTTP 200
error: NSURLErrorDomain -1200: A TLS error caused the secure connection to fail.

Приложение получает общую ошибку NSURLErrorSecureConnectionFailed (-1200), а не код, относящийся именно к закреплению, поэтому записывайте, с каким набором пинов вышла каждая версия приложения: без этого в отчётах о сбоях несовпадение пина после изменений на сервере ничем не отличается от любой другой ошибки TLS. NSIncludesSubdomains распространяет пин только на один уровень поддоменов (math.example.org, но не advanced.math.example.org). Совет Apple совпадает с советом Google: «в большинстве случаев закрепление не нужно, и его следует избегать», а если вы всё же закрепляете, закрепляйте ключи CA, причём больше одного.

Ротация пинов без блокировки пользователей

Набор пинов — это обещание на будущее, а приложение, в котором он зашит, может не обновляться целый год. Схема ротации, которая работает:

  1. Выпустите новый пин заранее, до того как он понадобится. Добавьте в набор пинов в одном из релизов корневой сертификат следующего CA или следующий ключ и дождитесь, пока версиями без него будет пользоваться лишь такая доля пользователей, сбой у которой вы можете себе позволить.
  2. Только после этого меняйте сервер. Смените CA или ключ; в старых версиях приложения старые пины по-прежнему совпадают, если среди них есть хотя бы один ключ из новой цепочки.
  3. Удалите устаревшие пины в одном из следующих релизов.
  4. Оставьте себе аварийный путь. Офлайн-ключ, который закреплён, но нигде не развёрнут, позволяет в экстренной ситуации получить на него сертификат у любого CA. Некоторые приложения также загружают обновления пинов из подписанной удалённой конфигурации; тогда этому файлу нужен собственный ключ подписи, который хранится вне TLS-инфраструктуры.
  5. Задавайте срок действия пинов в Android и считайте его крайним сроком следующей ротации, а не формальностью.
  6. Проверьте сценарий отказа. Запустите приложение через прокси с сертификатом, который входит в ваше хранилище доверенных сертификатов, но не попадает в набор пинов, и убедитесь, что соединение не устанавливается (fail closed), а приложение сообщает что-то полезное.

Корпоративные прокси с TLS-инспекцией по самой своей природе ломают приложения с закреплением: их сертификаты ведут к корпоративному корневому сертификату, который не закреплён. В Android приложения, ориентированные на уровень API 24 и выше, не доверяют CA, установленным пользователем, если конфигурация не добавляет их явно, и даже в этом случае к ним применяются пины, если не задан overridePins.

Типичные ошибки

  • Закрепление только конечного сертификата, когда ACME-клиент меняет ключи. Следующее продление обернётся сбоем.
  • Закрепление одного промежуточного сертификата у CA, который чередует несколько. Всё работает, пока CA не выберет другой.
  • Нет резервного пина или резервный пин от того же CA. Когда этот CA лишат доверия, оба пина перестанут работать одновременно.
  • Закрепление хеша сертификата вместо SPKI. Такой пин ломается при каждом продлении, даже если ключ остаётся прежним.
  • Самописные реализации TrustManager. Когда проверку платформы заменяют, чтобы «добавить закрепление», часто по случайности пропадает проверка имени хоста или цепочки. Используйте конфигурацию сетевой безопасности Android, CertificatePinner или NSPinnedDomains: они проверяют пины поверх обычной проверки.
  • Расчёт на то, что пины защитят от скомпрометированного устройства. На устройстве с root-доступом или джейлбрейком пользователь (или вредоносная программа) может вырезать закрепление патчем. Закрепление защищает пользователей от сети, а не ваш API от ваших пользователей.

Источники