18. TLS и HTTPS. Шифрование поверх TCP

Введение: HTTPS и место TLS в сетевом стеке

"Передавать незашифрованные данные через интернет — всё равно что отправлять открытку с номером банковской карты, надеясь на честность всех почтальонов мира."
— Принцип сетевой безопасности

Протокол TCP, который мы подробно исследовали в предыдущих главах, обеспечивает надежную доставку потока байтов. Но у TCP есть фундаментальный изъян: он абсолютно слеп к содержимому передаваемых данных. Для TCP нет разницы между случайным набором чисел, открытым паролем администратора или конфиденциальным банковским переводом. Любой промежуточный узел — маршрутизатор провайдера, открытая Wi-Fi точка в аэропорту или транзитный шлюз — может легко прочитать весь ваш трафик (Eavesdropping), подменить ответ (Tampering) или выдать себя за целевой сервер (Spoofing).

Здесь на сцену выходит TLS (Transport Layer Security, современный преемник устаревшего протокола SSL). TLS располагается в сетевом стеке аккуратно между транспортным уровнем (TCP) и прикладным уровнем (HTTP, gRPC, WebSocket).

Стек HTTPS — это не самостоятельный протокол, а классический HTTP, работающий поверх защищенного криптографического туннеля TLS:

TEXT
+-------------------------------------------------------+
|             Прикладной уровень: HTTP / gRPC           |
+-------------------------------------------------------+
|        Криптографический уровень: TLS 1.2 / TLS 1.3   |
+-------------------------------------------------------+
|             Транспортный уровень: TCP                 |
+-------------------------------------------------------+
|               Сетевой уровень: IP                     |
+-------------------------------------------------------+

В Go стандартом де-факто является пакет crypto/tls (рассматриваемый в главе 18. TLS и HTTPS. Шифрование поверх TCP), который бесшовно интегрирован в стандартную библиотеку net/http. Когда вы вызываете http.Client или запускаете http.Server со схемой https, рантайм Go автоматически оборачивает базовое сетевое соединение net.Conn в криптографическую обертку tls.Conn, загружает корневые сертификаты из системного хранилища и прозрачно управляет жизненным циклом сессии.

Почему чистый Go побеждает C-биндинги?

В других языках программирования работа с криптографией устроена иначе:

  • В PHP или раннем Python работа с криптографией традиционно делегируется внешнему C-расширению openssl. Каждое обращение к функции вроде openssl_crypt требует переключения контекста Cgo (или FFI), выделения структур в памяти Zval и жестко привязывает сервис к версии OpenSSL, установленной в дистрибутиве Linux. Уязвимости вроде Heartbleed требовали экстренного патчинга динамических библиотек всей ОС.
  • В Java аналогичная модель (SSLSocket / SSLContext) сильно перегружена сложной объектной иерархией, создавая высокое давление на Garbage Collector из-за обилия объектов SSLSession и Cipher.

Разработчики Go пошли по смелому пути: пакет crypto/tls реализован на чистом Go (Pure Go). Это избавляет от зависимости от сторонних .so библиотек, гарантирует безупречную кросс-компиляцию и позволяет рантайму филигранно оптимизировать аллокации памяти под управление сборщика мусора (GOGC и GOMEMLIMIT).


Архитектура протокола: Handshake и Record Layer

Протокол TLS состоит из двух четко разграниченных функциональных уровней:

flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    subgraph HS["1. Handshake Layer (Уровень согласования)"]
        H1["Аутентификация сторон через сертификаты X.509"]:::process
        H2["Согласование версии протокола и набора шифров (Cipher Suite)"]
        H3["Асимметричный обмен ключами (ECDHE / X25519)"]
        H4["Генерация общего симметричного мастер-ключа (Master Secret)"]
        H1 --> H2 --> H3 --> H4
    end

    subgraph RL["2. Record Layer (Уровень защищенной передачи данных)"]
        R1["Фрагментация потока на блоки (до 16 КБ)"]
        R2["Шифрование данных и вычисление MAC через AEAD"]:::process
        R3["Добавление 5-байтного заголовка TLS Record"]:::network
        R4["Передача зашифрованных байтов в TCP-сокет"]:::network
        R1 --> R2 --> R3 --> R4
    end

    HS ==>|"Передает сессионные ключи"| RL
  
  1. Handshake Layer (Уровень рукопожатия):
    Работает при установлении соединения. Стороны представляются друг другу, согласуют поддерживаемые криптографические алгоритмы (CipherSuites), проверяют цифровые подписи сертификатов (CA), обмениваются публичными ключами Диффи — Хеллмана и вырабатывают общий секрет (Master Secret), из которого выводятся симметричные сессионные ключи.
  2. Record Layer (Уровень записей):
    Работает непрерывно во время передачи данных приложения. Берет байты от http.Server или http.Client, нарезает их на блоки (TLS Records), шифрует с помощью симметричного шифра, добавляет имитовставку (AEAD nonce/tag) для гарантии целостности и упаковывает в кадры для отправки через TCP.

Эволюция: TLS 1.2 против TLS 1.3

В стандарте TLS 1.2 (RFC 5246) на установление защищенного соединения требовалось два полных кругосветных сетевых оборота (2-RTT) поверх уже выполненного TCP Handshake (суммарно 3 RTT до первого HTTP-байта!).

В современном стандарте TLS 1.3 (RFC 8446) архитектура была радикально переработана:

  • Рукопожатие сокращено до 1-RTT: Клиент уже в первом пакете ClientHello отправляет спекулятивные доли ключей для самых распространенных эллиптических кривых (X25519). Сервер сразу отвечает своими долями ключа, и шифрованный канал готов!
  • Поддержка режима 0-RTT: Для повторных соединений внедрен механизм сессионных билетов и предварительных ключей (PSK — Pre-Shared Keys), позволяющий отправлять полезные зашифрованные данные первого запроса в первом же пакете (0-RTT), хотя и с рисками Replay-атак.
  • Бескомпромиссная безопасность: Полностью удалены небезопасные алгоритмы прошлого века: статический обмен ключами RSA (не дававший свойства Forward Secrecy), блочные режимы с дополнением CBC (уязвимые к атакам Padding Oracle), а также хеши SHA1 и MD5.
  • Обязательное использование AEAD: Разрешены только современные аутентифицированные шифры: AES-GCM и ChaCha20-Poly1305.
sequenceDiagram
    autonumber
    participant Client as Клиент (Go http.Client)
    participant Server as Сервер (Go http.Server)

    Note over Client,Server: TLS 1.3 Handshake (1-RTT)
    Client->>Server: 1. ClientHello: Поддерживаемые CipherSuites, SNI + Key Share (X25519)
    Server->>Client: 2. ServerHello: Выбранный CipherSuite + Key Share + Сертификат (зашифрован!) + Finished
    Note over Client,Server: Симметричные ключи вычислены с обеих сторон!
    Client->>Server: 3. Finished + Первые зашифрованные данные прикладного уровня (HTTP GET)
    Server->>Client: 4. Зашифрованный HTTP ответ
  

Под капотом: crypto/tls и взаимодействие с рантаймом

В среде Go тип tls.Conn реализует стандартные интерфейсы net.Conn и io.ReadWriter.

Ключевая особенность: при создании сокета через tls.Client(conn, cfg) или tls.Server(conn, cfg) сетевой обмен не начинается немедленно. Рукопожатие инициируется лениво — при первом вызове Read() или Write(), либо явно через метод Handshake(). Если рукопожатие еще не завершено, горутина блокируется до завершения криптографической аутентификации.

Внутреннее устройство tls.Conn

Если мы заглянем в исходный код пакета crypto/tls (conn.go), структура tls.Conn содержит:

  • conn net.Conn — ссылка на физическое TCP-соединение.
  • config *tls.Config — конфигурация (сертификаты, поддерживаемые версии, таймауты).
  • handshakeStatus int32 — атомарный флаг состояния рукопожатия (0 — не выполнено, 1 — в процессе, 2 — завершено).
  • connState tls.ConnectionState — кэш метаданных активной сессии (выбранный шифр, версия, сертификаты пира).
  • Внутренние буферы: reader/writer — буферы Record Layer (включая кольцевой буфер чтения rd.buf типа []byte и буфер записи rawInput).
flowchart LR
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    subgraph TLS_CONN["Структура tls.Conn в Go"]
        direction TB
        RAW["Базовое соединение: conn net.Conn"]:::process
        CFG["Конфигурация: config *tls.Config"]:::network
        HS["Атомарный флаг: handshakeStatus"]:::process
        BUF["Буфер Record Layer: rd.buf"]:::process
        AEAD["Криптографический модуль: cipher.AEAD"]:::process
    end

    APP["Приложение: Read(userBuf)"] --> TLS_CONN
    TLS_CONN -->|"1. Чтение 5-байтного заголовка"| RAW
    RAW -->|"2. Получение зашифрованного sk_buff"| BUF
    BUF -->|"3. Дешифрация через AES-NI"| AEAD
    AEAD -->|"4. Передача открытого текста"| APP
  

При выполнении системного чтения tls.Conn.Read():

  1. Проверяется атомарный статус рукопожатия. Если рукопожатие не завершено, вызывается метод Handshake().
  2. Из сокета считывается фиксированный заголовок кадра TLS Record (ровно 5 байт: тип контента, версия протокола и 16-битная длина полезной нагрузки).
  3. Тело записи считывается во внутренний слайс rd.buf.
  4. Вызывается криптографический интерфейс дешифрации crypto/cipher.gcmCipher (или ChaCha20). Алгоритм сверяет аутентификационный тег (AEAD Tag), гарантируя, что ни один бит не был искажен.
  5. Расшифрованные данные копируются в буфер вызывающей программы.

Производительность и механика (Mechanical Sympathy)

Шифрование — это сугубо вычислительная операция (CPU-bound), а не ограничение пропускной способности ввода-вывода (I/O-bound). На высокоскоростных 10/40/100-гигабитных интерфейсах именно процессор становится главным «бутылочным горлышком» шифрования.

1. Кэш-линии и инструкции процессора

Современные процессоры x86-64 поддерживают аппаратный набор инструкций AES-NI (Advanced Encryption Standard New Instructions), а процессоры ARM64 (Apple Silicon, AWS Graviton) — инструкции ARMv8 Cryptography Extensions.

  • При наличии AES-NI раунды шифрования выполняются аппаратно внутри конвейера процессора с использованием 128/256-битных векторных регистров XMM/YMM за считанные такты.
  • Данные обрабатываются напрямую через регистры, не загрязняя кэши L1/L2 промежуточными таблицами подстановок (S-boxes).
  • Если шифрование выполняется в цикле, избегайте постоянных переаллокаций буферов в куче (make([]byte, N) внутри цикла): это вызовет ложные промахи мимо кэша (false cache misses) и заставит GC собирать мусор, пока процессор простаивает в ожидании инструкций шифрования.

2. Escape Analysis и GC давление

Каждое новое TLS-соединение требует инициализации криптографического состояния: парсинг сертификатов tls.Certificate, создание ключей, аллокации буферов. При создании сотен тысяч коротких соединений стандартный порог сборки мусора GOGC=100 может вызывать постоянные тормоза сервиса:

Go
// АНТИПАТТЕРН: катастрофические аллокации на каждый входящий запрос!
func handleBad(w http.ResponseWriter, r *http.Request) {
    // Вызов X509KeyPair парсит PEM-байты, выделяет память в куче на КАЖДЫЙ запрос!
    cert, _ := tls.X509KeyPair(certPEM, keyPEM) 
    cfg := &tls.Config{Certificates: []tls.Certificate{cert}}
    conn, _ := tls.Dial("tcp", "upstream:443", cfg)
    _ = conn
}

В высоконагруженных сервисах конфигурация tls.Config и сертификаты обязаны парситься один раз при старте сервиса или переиспользоваться через пулы:

Go
// ИДИОМАТИЧНЫЙ ПОДХОД: статическая конфигурация и переиспользование
var secureConfig *tls.Config

func init() {
    cert, err := tls.X509KeyPair(certPEM, keyPEM)
    if err != nil {
        panic(err)
    }
    secureConfig = &tls.Config{
        Certificates: []tls.Certificate{cert},
        MinVersion:   tls.VersionTLS13,
    }
}

3. Взаимодействие с netpoller и планировщиком G-M-P

Когда горутина вызывает tlsConn.Read(), а сетевой пакет еще летит по проводу, рантайм Go переводит горутину G в состояние ожидания Gwaiting. Планировщик Go отвязывает её от системного потока M, отправляя поток выполнять другие горутины из очереди runq.

Когда прерывание сетевой карты доставляет пакет, сетевой полинг netpoller (через epoll/kqueue) будит горутину G и возвращает её в runq.
Но обратите внимание: как только зашифрованные данные получены, фаза дешифрации выполняется синхронно на потоке M! Если сотни потоков одновременно нагружены расшифровкой криптографии, потоки M будут загружены на 100% CPU. Чтобы сетевой реактор не голодал, критически важно, чтобы GOMAXPROCS соответствовал реальному CPU-лимиту контейнера (по умолчанию он равен числу логических CPU NumCPU; начиная с Go 1.25 рантайм учитывает и cgroup-лимит CPU) и использовать мультиплексирование HTTP/2 или HTTP/3 поверх одного TLS-соединения.


Идиоматичная работа с TLS в Go

Напишем надежный, готовый к production клиент с валидацией цепочки доверия, поддержкой взаимной аутентификации (mTLS) и строгой проверкой доменного имени:

Go
package main

import (
    "context"
    "crypto/tls"
    "crypto/x509"
    "fmt"
    "log"
    "net"
    "time"
)

// establishSecureConn создает надежное TLS-соединение с защитой от MITM
func establishSecureConn(target string) (*tls.Conn, error) {
    // 1. Загрузка доверенного системного пула корневых сертификатов
    pool, err := x509.SystemCertPool()
    if err != nil {
        return nil, fmt.Errorf("ошибка загрузки системного пула CA: %w", err)
    }

    // 2. Настройка строгой конфигурации безопасности
    cfg := &tls.Config{
        RootCAs:            pool,
        MinVersion:         tls.VersionTLS12, // Минимальная допустимая версия (TLS 1.3 в Go включён по умолчанию с версии 1.13)
        // Серверное имя для расширения SNI (Server Name Indication)
        ServerName:         "example.com",
        GetClientCertificate: func(info *tls.CertificateRequestInfo) (*tls.Certificate, error) {
            // Механизм mTLS: динамическая отдача клиентского сертификата при запросе сервера
            cert, err := tls.LoadX509KeyPair("client.crt", "client.key")
            if err != nil {
                return nil, err
            }
            return &cert, nil
        },
    }

    // 3. Установка чистого TCP-соединения с таймаутом (без дедлайнов клиент может зависнуть)
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    dialer := &net.Dialer{Timeout: 5 * time.Second}
    conn, err := dialer.DialContext(ctx, "tcp", target)
    if err != nil {
        return nil, fmt.Errorf("ошибка TCP dial: %w", err)
    }

    // 4. Оборачивание в TLS и явный запуск Handshake с контекстом.
    // Handshake проверяет цепочку сертификатов и имя хоста (cfg.ServerName).
    tlsConn := tls.Client(conn, cfg)
    if err := tlsConn.HandshakeContext(ctx); err != nil {
        conn.Close()
        return nil, fmt.Errorf("ошибка TLS handshake: %w", err)
    }

    // 5. Явная повторная проверка имени хоста. При непустом ServerName и InsecureSkipVerify=false
    // это уже сделал Handshake; ручной VerifyHostname нужен, когда проверка отключена
    // (InsecureSkipVerify=true) и вы реализуете собственную проверку.
    if err := tlsConn.VerifyHostname("example.com"); err != nil {
        tlsConn.Close()
        return nil, fmt.Errorf("несовпадение доменного имени в сертификате: %w", err)
    }

    return tlsConn, nil
}

func main() {
    conn, err := establishSecureConn("example.com:443")
    if err != nil {
        log.Fatalf("Сбой безопасного соединения: %v", err)
    }
    defer conn.Close()

    fmt.Println("Безопасное соединение TLS успешно установлено и верифицировано!")
}

Ловушки и типичные вопросы на собеседованиях

  1. Смертоносный флаг InsecureSkipVerify: true:
    Этот флаг отключает проверку всей цепочки доверия CA и проверку имени хоста. Его часто включают разработчики для локального тестирования самоподписанных сертификатов. Попадая в production, этот флаг превращает ваш сервис в легкую добычу для любой MITM-атаки в локальной сети или облаке. Правильное решение — добавить корпоративный самоподписанный CA в конфигурационное поле RootCAs.
  2. Утечка памяти дескрипторов TLS:
    В отличие от некоторых высокоуровневых объектов других сред разработки, структура tls.Conn в Go не полагается на сборщик мусора для закрытия сокетов. Если горутина завершилась без вызова Close(), буферы Record Layer и дескрипторы сокетов зависнут в памяти. Всегда используйте defer conn.Close().
  3. Контроль памяти через GOMEMLIMIT:
    В высоконагруженных микросервисах с десятками тысяч одновременных TLS-сессий память, выделенная под буферы сокетов, способна оказаться значимой частью общего потребления процесса; при этом GOMEMLIMIT / debug.SetMemoryLimit() — это мягкий лимит для памяти, которой управляет рантайм Go, и он не учитывает буферы сокетов ядра и не вызывает «OOM-паники» при превышении (сборщик лишь работает чаще). Для ограничения потребления настраивайте параметры пула соединений в net/http.Transport: ограничивайте MaxIdleConns и выставляйте агрессивный таймаут простоя IdleConnTimeout.
  4. Сравнение сетевого шифрования в разных средах:
    • PHP: Жесткая привязка к системному OpenSSL. Вызовы через C-биндинги создают накладные расходы на маршалинг данных в структуры Zval.
    • Java: Использование абстракций SSLSocket, требующих выделения тяжелых объектов сессий в памяти JVM.
    • Go: Чистая и лаконичная реализация на Go, использующая композицию и интерфейсы net.Conn, векторные инструкции процессора AES-NI и переиспользование буферов через sync.Pool.

Итог

  1. HTTPS — это не отдельный протокол, а HTTP, инкапсулированный в защищенный криптографический туннель TLS.
  2. Два уровня TLS: Уровень рукопожатия (Handshake Layer) отвечает за аутентификацию и согласование мастер-ключа (Master Secret); уровень записей (Record Layer) шифрует и фрагментирует данные через шифры AEAD.
  3. Преимущества TLS 1.3: Сокращение фазы подключения до 1-RTT, поддержка нулевого оборота 0-RTT через PSK, исключение уязвимых алгоритмов (RSA, CBC, SHA1, MD5) и обязательное свойство Perfect Forward Secrecy.
  4. Архитектура Go: Пакет crypto/tls написан на чистом Go, оптимизирован под GOMEMLIMIT с использованием sync.Pool и аппаратно ускоряется векторными инструкциями процессора AES-NI. При отсутствии аппаратного AES рантайм автоматически переключается на ChaCha20-Poly1305.
  5. Безопасность: Никогда не используйте InsecureSkipVerify в production и всегда проводите валидацию доменных имен через VerifyHostname.

Мы разобрались с механикой шифрования и процессом передачи данных. Но как устроена всемирная инфраструктура цифровых сертификатов (PKI)? Как браузеры и Go-сервисы проверяют подлинность открытых ключей и как настроить защищенное мессервисное взаимодействие с помощью mTLS? В следующей статье мы исследуем: 19. Сертификаты, PKI, mTLS и процесс TLS Handshake.