Skip to content

TLS и закрепление сертификатов

Управление версиями TLS

HTTPC по умолчанию требует TLS 1.2+, отклоняя доказанно небезопасные TLS 1.0/1.1. Рекомендуется TLS 1.3 для более сильной прямой секретности и более простого рукопожатия:

go
cfg := httpc.DefaultConfig()
cfg.Security.MinTLSVersion = tls.VersionTLS12  // по умолчанию
cfg.Security.MaxTLSVersion = tls.VersionTLS13  // по умолчанию

Описание версий

ВерсияСтатусПо умолчанию в HTTPC
TLS 1.0Небезопасна, устарела (POODLE/BEAST)Отклоняется
TLS 1.1Небезопасна, устарелаОтклоняется
TLS 1.2БезопаснаМинимальное требование
TLS 1.3Наиболее безопасна, рекомендуется (принудительная прямая секретность)Поддерживается

TIP

Для принудительного использования только TLS 1.3 (более высокая безопасность, более простое рукопожатие) установите MinTLSVersion = tls.VersionTLS13. Обратите внимание: некоторые старые клиенты/прокси могут не поддерживать TLS 1.3 — перед включением убедитесь в совместимости целевого сервиса.

WARNING

После установки Security.TLSConfig значения MinTLSVersion и MaxTLSVersion игнорируются — приоритет у настроек в TLSConfig. Для управления версиями в пользовательском TLSConfig установите TLSConfig.MinVersion / MaxVersion.

Шифрские наборы

Конфигурация по умолчанию допускает только безопасные шифрские наборы (серия ECDHE с принудительной прямой секретностью, AEAD-шифрование):

Шифрский наборОписание
TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256Рекомендуется
TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384Рекомендуется
TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305Рекомендуется
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256Рекомендуется
TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384Рекомендуется
TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305Рекомендуется

Шифрские наборы TLS 1.3 согласовываются протоколом автоматически и не управляются полем CipherSuites.

Пользовательская конфигурация TLS

При необходимости тонкого контроля (пользовательский CA, mTLS, фиксация шифрских наборов) установите Security.TLSConfig:

go
cfg := httpc.DefaultConfig()
cfg.Security.TLSConfig = &tls.Config{
    MinVersion: tls.VersionTLS13,  // Принудительный TLS 1.3
    // Другие пользовательские настройки
}

Пользовательский CA-сертификат

При подключении к сервисам с сертификатами внутреннего CA (корпоративный PKI, самоподписанные сертификаты) загрузите пользовательский корневой сертификат:

go
package main

import (
    "crypto/tls"
    "crypto/x509"
    "log"
    "os"

    "github.com/cybergodev/httpc"
)

func main() {
    caCert, err := os.ReadFile("custom-ca.pem")
    if err != nil {
        log.Fatal(err)
    }
    caCertPool := x509.NewCertPool()
    if !caCertPool.AppendCertsFromPEM(caCert) {
        log.Fatal("Не удалось разобрать CA-сертификат")
    }

    cfg := httpc.DefaultConfig()
    cfg.Security.TLSConfig = &tls.Config{
        RootCAs:    caCertPool,
        MinVersion: tls.VersionTLS12,
    }

    client, err := httpc.New(cfg)
    if err != nil {
        log.Fatal(err)
    }
    defer func() { _ = client.Close() }()
    log.Println("Клиент с пользовательским CA готов")
}

Взаимная аутентификация TLS (mTLS)

Когда сервер требует клиентский сертификат (mTLS), настройте поле Certificates:

go
package main

import (
    "crypto/tls"
    "log"

    "github.com/cybergodev/httpc"
)

func main() {
    cert, err := tls.LoadX509KeyPair("client-cert.pem", "client-key.pem")
    if err != nil {
        log.Fatal(err)
    }

    cfg := httpc.DefaultConfig()
    cfg.Security.TLSConfig = &tls.Config{
        Certificates: []tls.Certificate{cert},
        MinVersion:   tls.VersionTLS12,
    }

    client, err := httpc.New(cfg)
    if err != nil {
        log.Fatal(err)
    }
    defer func() { _ = client.Close() }()
    log.Println("mTLS-клиент готов")
}

TIP

mTLS часто применяется в сетях нулевого доверия, внутренней аутентификации сервисных сетей, финансовых API. Сервер идентифицирует вызывающую сторону по клиентскому сертификату без дополнительного токена. При ротации сертификата нужно синхронно обновлять client-cert.pem / client-key.pem.

Закрепление сертификатов

Закрепление сертификатов (Certificate Pinning) добавляет дополнительный уровень проверки поверх стандартной проверки цепочки сертификатов: требует наличия известного закреплённого публичного ключа/сертификата в серверной цепочке. Даже при компрометации доверенного CA или принуждении к выпуску подделанного сертификата злоумышленник не сможет провести атаку «человек посередине» — поскольку публичный ключ его сертификата не соответствует закреплённому значению.

Принцип работы

Стандартная TLS-валидация: клиент доверяет любому сертификату, подписанному доверенным CA. Закрепление сертификатов: клиент дополнительно требует совпадения хеша публичного ключа сертификата с предзаданным значением.

Стандартная проверка:  доверие CA → доверие любому сертификату, подписанному CA
Закрепление:           доверие CA + публичный ключ сертификата должен совпадать с предзаданным хешем

Закрепление наслаивается поверх стандартной проверки и не требует установки InsecureSkipVerify. HTTPC проверяет сертификат на любом уровне цепочки, поэтому при закреплении промежуточного сертификата оно остаётся действительным и после продления листового сертификата.

Шаги генерации SPKI-хеша

SPKI (SubjectPublicKeyInfo) хеш — наиболее распространённый формат закрепления (стандарт HPKP). Шаги генерации:

Шаг 1: получение сертификата сервера (экспорт из браузера или получение через openssl)

bash
# Получение цепочки сертификатов с сервера
openssl s_client -connect example.com:443 -showcerts < /dev/null 2>/dev/null \
  | openssl x509 -outform pem > cert.pem

Шаг 2: извлечение публичного ключа из сертификата → DER-кодирование → SHA-256 → base64

bash
openssl x509 -in cert.pem -pubkey -noout | \
  openssl pkey -pubin -outform der | \
  openssl dgst -sha256 -binary | \
  openssl enc -base64
# Вывод: YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2fuihg=

Разбор трёх шагов:

ШагФрагмент командыНазначение
Извлечение публичного ключаopenssl x509 -pubkey -nooutИзвлечение PEM-формата публичного ключа из X.509 сертификата
DER-кодированиеopenssl pkey -pubin -outform derПреобразование PEM-ключа в формат DER (PKIX)
Хеширование и кодированиеopenssl dgst -sha256 -binary | openssl enc -base64SHA-256 с последующим base64-кодированием

TIP

Рекомендуется закреплять SPKI промежуточного сертификата, а не листового. Промежуточный сертификат имеет длительный срок действия (обычно 5-10 лет), при продлении листового сертификата (например, Let's Encrypt 90 дней) промежуточный не меняется — закреплённое значение не нужно часто обновлять.

Закрепление SPKI-хеша (рекомендуется)

NewSPKIHashPinner принимает один или несколько base64-кодированных SHA-256 SPKI-хешей. Указание нескольких хешей поддерживает ротацию ключей — совпадение любого считается успехом:

go
package main

import (
    "crypto/tls"
    "log"

    "github.com/cybergodev/httpc"
)

func main() {
    pinner, err := httpc.NewSPKIHashPinner(
        "YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2fuihg=", // Текущий промежуточный сертификат
        "C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=", // Резервный (ротация ключей)
    )
    if err != nil {
        log.Fatal(err)
    }

    cfg := httpc.DefaultConfig()
    cfg.Security.MinTLSVersion = tls.VersionTLS12
    cfg.Security.CertificatePinner = pinner
    client, err := httpc.New(cfg)
    if err != nil {
        log.Fatal(err)
    }
    defer func() { _ = client.Close() }()
    log.Println("Клиент с закреплением сертификатов готов")
}

NewSPKIHashPinner возвращает ошибку при пустом хеше или некорректном base64, выявляя проблемы конфигурации на этапе запуска. Логика проверки: перебор каждого сертификата серверной цепочки на каждом уровне, вычисление SHA-256 его SPKI и сравнение с любым из закреплённых хешей.

TIP

CertificatePinner дополняет стандартную проверку цепочки TLS и не требует установки InsecureSkipVerify. Проверка применяется к сертификату на любом уровне цепочки, поэтому закрепление промежуточного сертификата остаётся действующим после продления листового.

Сравнение трёх конструкторов Pinner

HTTPC предоставляет три конструктора Pinner для разных сценариев:

КонструкторВходСценарий примененияРекомендация
NewSPKIHashPinnerbase64 SHA-256 SPKI-хешНаиболее частый (формат HPKP)Рекомендуется
NewPublicKeyPinnerDER-кодированный PKIX публичный ключПри наличии исходных байтов ключаУдобно
NewCertificatePinnerChainНесколько PinnerКомбинирование стратегий/смешанная ротация ключейПродвинутый
go
// 1. SPKI-хеш (рекомендуется, наиболее частый)
spkiPinner, err := httpc.NewSPKIHashPinner(
    "YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2fuihg=",
)

// 2. DER публичный ключ (уже есть исходные байты ключа, внутренне вычисляется SHA-256)
pubPinner, err := httpc.NewPublicKeyPinner(pubKeyDER1, pubKeyDER2)

// 3. Комбинирование нескольких pinner, принимается любой (смешанные стратегии или ключи ротации разных конструкторов)
chainPinner := httpc.NewCertificatePinnerChain(spkiPinner, pubPinner)
cfg.Security.CertificatePinner = chainPinner

NewCertificatePinnerChain без аргументов возвращает Pinner «отклонять всех» (безопасное умолчание), гарантируя, что пропуск конфигурации не будет тихо пропущен как «разрешить всех».

Пользовательский CertificatePinner

В продвинутых сценариях с необходимостью пользовательской стратегии закрепления (например, закрепление полного сертификата вместо публичного ключа, динамическое получение закреплённых значений из конфигурационного центра) можно напрямую реализовать интерфейс CertificatePinner:

go
package main

import (
    "crypto/sha256"
    "crypto/tls"
    "crypto/x509"
    "encoding/base64"
    "errors"
    "log"

    "github.com/cybergodev/httpc"
)

// fullCertPinner закрепляет SHA-256 полного сертификата (а не SPKI публичного ключа).
type fullCertPinner struct {
    pinnedHashes map[string]bool
}

func newFullCertPinner(hashes ...string) *fullCertPinner {
    m := make(map[string]bool, len(hashes))
    for _, h := range hashes {
        m[h] = true
    }
    return &fullCertPinner{pinnedHashes: m}
}

// Pin реализует интерфейс CertificatePinner, возвращая описание закреплённого значения (для логов/отладки).
func (p *fullCertPinner) Pin() string {
    return "full-cert-pinner"
}

// VerifyPeerCertificate реализует интерфейс CertificatePinner.
// Возврат nil означает принятие, не-nil отклоняет рукопожатие.
func (p *fullCertPinner) VerifyPeerCertificate(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
    if len(verifiedChains) == 0 || len(verifiedChains[0]) == 0 {
        return errors.New("certificate pinning failed: empty chain")
    }
    for _, cert := range verifiedChains[0] {
        sum := sha256.Sum256(cert.Raw)
        hash := base64.StdEncoding.EncodeToString(sum[:])
        if p.pinnedHashes[hash] {
            return nil // Совпадение, принято
        }
    }
    return errors.New("certificate pinning failed: no matching certificate")
}

func main() {
    // Закрепление SHA-256 полного листового сертификата (при продлении сертификата значение нужно обновить)
    pinner := newFullCertPinner(
        "wert6uY/PCq3yAAbZA/wtqfzfQsTwmxnfv6I3vRz1XQ=", // SHA-256 от cert.Raw
    )

    cfg := httpc.DefaultConfig()
    cfg.Security.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12}
    cfg.Security.CertificatePinner = pinner
    client, err := httpc.New(cfg)
    if err != nil {
        log.Fatal(err)
    }
    defer func() { _ = client.Close() }()
    log.Println("Клиент с пользовательским закреплением сертификатов готов")
}

WARNING

При реализации пользовательского CertificatePinner обязательно наслаивайте проверку закрепления поверх стандартной проверки цепочки сертификатов (verifiedChains). Никогда не заменяйте им стандартную проверку — всегда держите InsecureSkipVerify = false.

Множественные хеши и ротация ключей

NewSPKIHashPinner и NewPublicKeyPinner принимают несколько значений — совпадение любого считается успехом. Это ключ к ротации ключей: одновременно закрепляются старый и новый публичные ключи, в период ротации проходят как старые, так и новые сертификаты — переключение без остановки.

Процесс ротации:

  1. Сервер генерирует новую пару ключей и выпускает новый сертификат
  2. Клиент обновляет закреплённые значения, одновременно сохраняя старый и новый хеши:
    go
    pinner, _ := httpc.NewSPKIHashPinner(
        "OLD_HASH...", // Старый ключ (сохранять в период ротации)
        "NEW_HASH...", // Новый ключ (скоро будет включён)
    )
  3. Развёртывание клиента, подтверждение прохождения как старого, так и нового сертификата
  4. Сервер переключается на новый сертификат
  5. По истечении периода наблюдения без сбоев старый хеш удаляется из закреплённых значений

DANGER

Сбой закрепления (несовпадение закреплённых значений) приведёт к полному отказу соединения без возможности автоматического восстановления. Обязательно:

  • Всегда сохраняйте хотя бы один резервный закреплённый ключ на случай повреждения основного
  • Применяйте стратегию ротации «сначала добавить новый, затем удалить старый» с двойным окном
  • Настройте мониторинг с быстрым откатом клиентской конфигурации при сбоях закрепления

Закрепление сертификатов и Let's Encrypt

Сертификаты Let's Encrypt действительны только 90 дней и часто продляются. Прямое закрепление листового сертификата потребует обновления закреплённого значения каждые 90 дней — высокие расходы на поддержку. Рекомендуемая стратегия:

Объект закрепленияСрок действияВлияние продленияРекомендация
Листовой сертификат90 днейОбновление закреплённого значения при каждом продленииНизкая
Промежуточный сертификат Let's EncryptОколо 5 летОбновление только при ротации промежуточного (раз в несколько лет)Рекомендуется
Публичный ключ вашего домена (при самохостинге)Под вашим контролемОбновление только при вашей активной ротации ключаПо обстоятельствам

Промежуточные сертификаты Let's Encrypt (R3, R10, R11 и др.) действительны несколько лет — закрепление SPKI промежуточного сертификата обеспечивает баланс безопасности и низких расходов на поддержку. При ротации промежуточного сертификата Let's Encrypt объявляет об этом заранее, позволяя спокойно обновить закреплённое значение.

go
// Закрепление промежуточного сертификата Let's Encrypt (рекомендуется)
pinner, err := httpc.NewSPKIHashPinner(
    "YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2fuihg=", // Текущий промежуточный сертификат
    "C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=", // Резервный/следующий промежуточный сертификат
)

TIP

Публичный ключ промежуточного сертификата Let's Encrypt при ротации обычно остаётся неизменным (выпускается только новый сертификат, ключ переиспользуется). Закрепление SPKI публичного ключа стабильнее, чем закрепление полного промежуточного сертификата — при неизменном ключе SPKI-хеш не меняется.

Соображения безопасности закрепления сертификатов

РискПоследствиеМеры снижения
Истечение/несовпадение закреплённого значенияПолный отказ соединенияМножественные хеши + резервные ключи + мониторинг
Потеря резервного ключаНевозможность ротации, блокировкаОфлайн-резервное копирование нескольких пар ключей
Хардкод закреплённых значенийОбновление требует релизаДинамическая загрузка из конфигурационного центра (пользовательский Pinner)
Закрепление только одного уровняЕдиная точка отказаЗакрепление нескольких уровней (листовой + промежуточный)

WARNING

Закрепление сертификатов — «обоюдоострый меч»: значительно повышает защиту от MITM/компрометации CA, но сбой закрепления приводит к жёсткому отказу. Перед применением убедитесь:

  1. Наличие надёжного механизма обновления и распространения закреплённых значений
  2. Мониторинг частоты сбоев закрепления с настройкой оповещений
  3. Наличие аварийного переключателя «отключить закрепление» (хотя это снижает безопасность)

Сравнение стратегий закрепления

СтратегияБезопасностьСтоимость поддержкиРекомендуемый сценарий
Закрепление корневого сертификатаНизкаяНизкаяТолько защита от подмены, диапазон CA слишком широк
Закрепление промежуточного сертификатаСредняяСредняяРекомендуется, баланс безопасности и поддержки
Закрепление листового сертификатаВысокаяВысокаяВысокая безопасность, контролируемые сертификаты
Закрепление нескольких уровнейВысокаяСредняяНаилучший вариант, многоуровневая избыточность

InsecureSkipVerify

InsecureSkipVerify пропускает всю проверку цепочки TLS-сертификатов, используется только для тестирования:

go
// Только для тестирования!
cfg := httpc.TestingConfig()
// InsecureSkipVerify = true → пропуск проверки TLS-сертификатов

HTTPC при обнаружении InsecureSkipVerify = true в httpc.New() вне тестовой среды выводит предупреждение в stderr (один раз на процесс). Определение тестовой среды: исполняемый файл с суффиксом .test или установлены переменные окружения GO_TEST / GOTEST=1.

DANGER

InsecureSkipVerify = true делает все меры безопасности TLS недействительными (включая закрепление сертификатов), используйте только в тестовой среде. Никогда не устанавливайте true в продакшене. При необходимости пользовательской логики проверки (например, закрепление полного сертификата) реализуйте интерфейс CertificatePinner, а не пропускайте проверку.

HTTP/2

HTTP/2 включён по умолчанию и доступен только при использовании TLS (h2 через ALPN):

go
cfg := httpc.DefaultConfig()
cfg.Connection.EnableHTTP2 = false // Отключить HTTP/2 (только HTTP/1.1)

TIP

При включении закрепления сертификатов или пользовательского TLSConfig согласование ALPN для HTTP/2 продолжает работать корректно. Для отключения HTTP/2 (например, отладка или совместимость со старыми прокси) установите EnableHTTP2 = false.

Лучшие практики

  1. Используйте конфигурацию TLS по умолчанию (TLS 1.2+), ручная настройка не требуется
  2. При закреплении сертификатов закрепляйте SPKI промежуточного сертификата с подготовкой резервных значений
  3. Множественные хеши поддерживают ротацию ключей — используйте стратегию «сначала добавить новый, затем удалить старый»
  4. Регулярно обновляйте закреплённые значения синхронно с продлением серверных сертификатов
  5. Используйте SecureConfig() как базовую линию безопасности
  6. Никогда не устанавливайте InsecureSkipVerify в продакшене
  7. В высокобезопасных сценариях закрепляйте многоуровневые сертификаты (листовой + промежуточный) для избыточности

Что дальше