3. TLS и HTTPS под капотом

Рукопожатие, криптографический стек и рантайм Go

«Сетевые протоколы — это нервная система современных вычислений: делайте их безопасными, делайте их быстрыми и делайте их простыми».
— Брайан Керниган

Для большинства пользователей интернета протокол HTTPS ассоциируется с иконкой закрытого замочка в адресной строке браузера. Но для бэкенд-инженера TLS (Transport Layer Security) — это сложнейший криптографический протокол, работающий прямо на границе между прикладным кодом и системными сокетами ядра операционной системы.

В экосистеме Go архитектура TLS уникальна: в отличие от C/C++ (где используются внешние библиотеки libssl / OpenSSL, BoringSSL или mbedTLS с ручным управлением структурами SSL_CTX, SSL* и вызовами free()) или Python/Java (делегирующих работу через FFI/JNI), стандартная библиотека Go содержит полную, самодостаточную реализацию TLS на чистом Go — пакет crypto/tls.

Она скомпилирована статически, тесно интегрирована с системным планировщиком Go (netpoll, epoll/kqueue) и управлением памятью, обеспечивая феноменальную скорость и исключая классические уязвимости переполнения буфера в C.


Фундамент защищённых соединений: от рукопожатия до передачи байт

flowchart LR
    subgraph Handshake["TLS 1.3 Handshake (1-RTT)"]
        direction TB
        C["Клиент (Браузер / gRPC-клиент)"]
        S["Сервер (Go net/http / tls.Listener)"]

        C -->|"1. ClientHello<br/>версия, cipher suites, key share"| S
        S -->|"2. ServerHello<br/>выбор параметров, key share, cert"| C
        S -->|"3. ServerFinished<br/>подтверждение готовности"| C
        C -->|"4. ClientFinished<br/>подтверждение готовности"| S
    end

    subgraph DataTransfer["Передача данных"]
        direction TB
        SecChan["Защищённый канал<br/>(AES-GCM / ChaCha20-Poly1305)"]
        SecChan -->|"Прикладные данные<br/>HTTP/2 gRPC"| C
        SecChan -->|"Прикладные данные"| S
    end

    Handshake -.-> DataTransfer

    style C fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    style S fill:#1e293b,stroke:#10b981,stroke-width:2px,color:#f8fafc
    style SecChan fill:#1e293b,stroke:#f59e0b,stroke-width:2px,color:#f8fafc
  

Эволюция рукопожатия: TLS 1.2 против 1.3

Представьте дипломатические переговоры через океан:

  • В эпоху TLS 1.2 рукопожатие требовало двух полных круговых задержек (2-RTT). Сначала клиент и сервер приветствовали друг друга и договаривались о списке поддерживаемых алгоритмов (cipher suites), затем сервер присылал сертификат (cert), клиент генерировал предварительный секрет, шифровал его и отправлял обратно, и лишь на четвертом шаге начиналась передача данных. Если сетевой пинг через океан составлял 100 мс, то перед отправкой первого байта HTTP-запроса соединение простаивало 400 миллисекунд!
  • В современном стандарте TLS 1.3 (RFC 8446) рукопожатие сокращено вдвое — ровно до 1-RTT. Клиент оптимистично предполагает, что сервер поддерживает современные эллиптические кривые, и прямо в первом пакете ClientHello отправляет свой публичный ключ Диффи-Хеллмана (key share). Сервер отвечает своим key share, сертификатом и подтверждением ServerFinished. Сразу после этого обе стороны вычисляют общий секрет!

Из TLS 1.3 беспощадно удалили весь устаревший и небезопасный легаси-код: статический RSA key exchange, поточный шифр RC4, функцию хеширования SHA-1 и блочные режимы CBC с уязвимым выравниванием. Единственным механизмом обмена ключами остался ECDHE (Elliptic Curve Diffie-Hellman Ephemeral), гарантирующий Perfect Forward Secrecy (PFS): даже если приватный ключ сервера будет похищен через 5 лет, злоумышленник не сможет расшифровать записанный трафик.


Интеграция с рантаймом Go: как crypto/tls обёртывает сокет

В стандартной библиотеке Go тип tls.Conn является классическим паттерном «Декоратор» вокруг базового интерфейса сетевого сокета net.Conn.

Физический конвейер обработки данных:

  1. Системный вызов ядра Linux (epoll_wait) через планировщик netpoll уведомляет рантайм Go о готовности входящих байтов в сокете.
  2. Горутина захватывает сырые данные вызовом syscall.Read (или syscall.read/write).
  3. Парсер crypto/tls разбирает 5-байтовый заголовок TLS-записи (Record Header: тип контента, версия протокола, длина фрагмента до 16 КБ).
  4. Аппаратный блок процессора выполняет расшифровку блоков прямо в памяти процесса (AES/ChaCha20).
  5. Декодированные прикладные данные передаются вышестоящему обработчику net/http или gRPC (HTTP/2).
Go
package tls_example

import (
    "crypto/tls"
    "log"
    "net"
)

// Идиоматичная настройка TLS-сервера в Go
func runTLSServer(certFile, keyFile string) {
    // Загрузка сертификата и приватного ключа
    cert, err := tls.LoadX509KeyPair(certFile, keyFile)
    if err != nil {
        log.Fatalf("load cert: %v", err)
    }

    // Конфигурация TLS: строгие современные параметры
    cfg := &tls.Config{
        Certificates: []tls.Certificate{cert},
        MinVersion:   tls.VersionTLS13,               // Отключение устаревших версий (TLS 1.0, 1.1, 1.2)
        NextProtos:   []string{"h2", "http/1.1"},    // ALPN для согласования протоколов HTTP/2 и HTTP/1.1
    }

    ln, err := net.Listen("tcp", ":443")
    if err != nil {
        log.Fatalf("listen: %v", err)
    }

    // tls.Listener оборачивает входящие TCP-соединения в защищенные сессии
    tlsLn := tls.NewListener(ln, cfg)
    defer tlsLn.Close()

    log.Println("TLS server listening on :443")
    for {
        // tlsLn.Accept() выполняет TCP accept, рукопожатие происходит при первом Read/Write
        conn, err := tlsLn.Accept()
        if err != nil {
            log.Printf("accept: %v", err)
            continue
        }

        // Каждая горутина обслуживает одно TLS-соединение
        go handleConnection(conn)
    }
}

func handleConnection(conn net.Conn) {
    defer conn.Close()
    buf := make([]byte, 1024)
    for {
        n, err := conn.Read(buf)
        if err != nil {
            return
        }
        conn.Write(buf[:n])
    }
}


Механическое сочувствие: системные вызовы, кэш и CPU

TLS добавляет измеримые физические затраты на каждом такте:

  1. Системные вызовы ядра: Каждый вызов conn.Read()/Write() в tls.Conn может транслироваться в несколько системных вызовов syscall.read/write для агрегации 16-килобайтного фрейма TLS. netpoll эффективно мультиплексирует дескрипторы через epoll_wait (Linux) или kqueue (macOS), но криптографическая обработка происходит в User Space.
  2. Нагрузка на ALU и аппаратные инструкции: Для симметричного шифрования AES-GCM процессор задействует инструкции PCLMULQDQ и AES-NI. Однако проверка целостности и сверка MAC требуют работы с кэшем. При десятках тысяч одновременных горутин данные сессионных ключей непрерывно конкурируют за кэш-линии L1/L2, вызывая задержки обращения к DRAM (memory stall).
  3. Синхронизация состояния: tls.Conn не поддерживает одновременную параллельную запись из нескольких горутин. В net/http эта проблема решена архитектурно: одна горутина выделяется на чтение, а запись буферизируется через внутренние мьютексы.

Продвинутые механизмы: Resumption, SNI и 0-RTT

  • Возобновление сессий (Session Resumption / Session Tickets):
    Чтобы не тратить ресурсы CPU на вычисление эллиптических кривых при каждом повторном подключении, сервер выдает клиенту зашифрованный тикет (Session Tickets). При следующем подключении клиент передает этот тикет, и сервер восстанавливает сессию без тяжелой асимметричной математики. В Go это настраивается параметрами TicketKey(s) и SessionTicketsDisabled. Секретные ключи шифрования тикетов обязаны ротироваться каждые 12–24 часа для поддержания PFS.
  • SNI (Server Name Indication):
    Позволяет размещать сотни различных доменов и SSL-сертификатов на одном IP-адресе. В Go динамическая диспетчеризация реализуется через колбэк tls.Config.GetCertificate или GetConfigForClient.
  • 0-RTT (Early Data):
    Режим TLS 1.3, позволяющий клиенту отправлять полезную нагрузку HTTP-запроса непосредственно в первом пакете ClientHello (вообще без задержки рукопожатия!). Однако 0-RTT фундаментально уязвим к Replay-атакам: перехватив этот пакет, атакующий может повторно списать деньги со счета или отправить дублирующее сообщение. В Go tls.Conn.SetSessionTicketKeys() управляет этой логикой, но в net/http режим 0-RTT по умолчанию отключен ради безопасности.


Валидация цепочек и OCSP Stapling

По умолчанию crypto/tls проверяет цепочку сертификатов серверов против системных доверенных корневых центров сертификации (crypto/x509.SystemCertPool()). В ультра-минималистичных Docker-образах (Scratch, Distroless, Alpine) системный набор корневых сертификатов часто отсутствует, что вызывает фатальную ошибку рантайма: x509: failed to load system roots. Обязательно устанавливайте пакет ca-certificates на этапе сборки контейнера.

OCSP Stapling:
Вместо того чтобы клиент сам обращался к серверу удостоверяющего центра (CA) за проверкой отзыва сертификата (раскрывая третьим лицам факт посещения сайта и добавляя сетевую задержку), сервер сам периодически запрашивает криптографически подписанную квитанцию об отзыве и «прикрепляет» ее к рукопожатию. В Go для высокой доступности кэшируйте ответы в потокобезопасной sync.Map с контролем TTL.



Сравнение подходов: Go против C++ и Python

  • C++: Полностью зависит от динамических библиотек OpenSSL, BoringSSL или mbedTLS. Требует филигранного управления жизненным циклом SSL_CTX, SSL* и освобождения памяти через free(). Любая CVE в системной OpenSSL затрагивает все демоны на хосте.
  • Python / Java: Делегируют TLS системному модулю ssl (обертка над OpenSSL) или Java JSSE. Удобны, но не позволяют тюнить низкоуровневые параметры планировщика сокетов без FFI.
  • Go: Полная статическая линковка пакета crypto/tls. Нулевая зависимость от версий libssl.so в операционной системе, безупречная безопасность памяти. Обновление криптографических возможностей управляется релизом компилятора или версиями зависимостей в go.mod, обеспечивая предсказуемость сборки.

Итог

  1. TLS в Go — это самодостаточная, типобезопасная реализация на чистом Go, оборачивающая net.Conn и напрямую использующая асинхронный планировщик netpoll.
  2. TLS 1.3 сокращает задержку до 1-RTT, делает Perfect Forward Secrecy обязательным благодаря ECDHE и исключает уязвимые симметричные режимы и алгоритмы прошлого.
  3. Механическое сочувствие: Учитывайте накладные расходы на парсинг сертификатов в encoding/asn1, избегайте аллокаций в куче, применяйте аппаратное ускорение AES-NI и PCLMULQDQ.
  4. Запрет блокировок в Handshake: Функции GetCertificate и GetConfigForClient обязаны работать с атомарными структурами в оперативной памяти без походов в базу данных или сеть.
  5. Строгая валидация: Никогда не используйте InsecureSkipVerify = true в продакшене. Настраивайте RootCAs и Subject Alternative Name (SAN).

В следующей лекции мы погрузимся в прикладную работу с инструментами стандартной библиотеки Go: 4. Работа с crypto пакетом Go.