HTTPS и TLS: что зашифровано, что нет, и как клиент это проверяет
Junior часто думает, что HTTPS — это «магия», которая «делает всё безопасным». На самом деле HTTPS — это просто HTTP, обёрнутый в TLS-туннель. Понять, как это работает на уровне пакетов и сертификатов, важно: это объясняет, почему requests.get(url, verify=False) — небезопасно, почему атакующий в кафешке видит, на какой сайт вы зашли, и почему сертификат внезапно перестал работать в 03:00 утра в выходной.
В этом уроке разберём: что такое TLS, что именно скрывается за шифрованием, как устроен handshake, как клиент проверяет цепочку сертификатов, и как ведут себя curl и Python.
HTTPS = HTTP + TLS
HTTPS — это не «другой протокол». Это та же самая семантика HTTP (метод, путь, headers, body, статус), но передаётся не в открытом TCP-сокете, а внутри TLS-туннеля. TLS (Transport Layer Security) — это криптографический протокол, который сидит между TCP и HTTP.
HTTPS добавляет один слой между TCP и HTTP
Когда вы делаете https://api.github.com/users/octocat, клиент:
- Резолвит
api.github.comв IP-адрес через DNS (обычно не зашифровано, если у вас нет DoH/DoT). - Открывает TCP-соединение на порт 443.
- Запускает TLS handshake (об этом ниже).
- После handshake — внутри туннеля отправляет обычный HTTP-запрос:
GET /users/octocat HTTP/1.1\r\nHost: api.github.com\r\n.... - Получает HTTP-ответ внутри того же туннеля.
Для приложения это прозрачно: библиотека requests принимает URL, остальное за неё делает OpenSSL.
Что зашифровано, что нет
Это важный момент. HTTPS не делает вас невидимым в сети.
Шифрование защищает содержимое запроса, но не сам факт соединения
Практический вывод для data engineer: даже если вы тянете данные через HTTPS, ваш сетевой администратор и провайдер знают, с каким API вы общаетесь (по SNI и IP). Они не знают, что именно вы запросили и какие данные получили. Это всё, что вы получаете от HTTPS на сетевом уровне.
SNI в clear-text — это решение совместимости. Без SNI один IP мог обслуживать только один TLS-сертификат, что было неприемлемо для shared hosting. ECH (Encrypted Client Hello) шифрует SNI, но требует поддержки и на клиенте, и на сервере. Cloudflare уже поддерживает ECH; в curl 8.10+ есть --ech флаг. К 2026 году ECH постепенно становится default-ом, но в production-API он встречается редко.
TLS handshake: упрощённая модель
TLS 1.3 handshake пошагово: от ClientHello до 0-RTTКогда клиент устанавливает HTTPS-соединение, происходит TLS handshake. В TLS 1.3 он стал проще и быстрее (1-RTT вместо 2-RTT в TLS 1.2). Разберём шаги.
ClientHello, ServerHello + сертификат, проверка, переход к шифрованному обмену
Ключевые моменты:
- Diffie-Hellman key exchange: клиент и сервер обмениваются публичными ключами, и каждый независимо вычисляет общий secret. Атакующий, наблюдающий за каналом, не может вычислить этот secret даже зная оба публичных ключа (это математически защищено). Этот secret используется как ключ для симметричного шифрования (AES-GCM).
- Forward secrecy: каждое соединение использует новый эфемерный DH-ключ. Если атакующий завтра украдёт приватный ключ сервера, он не сможет расшифровать вчерашние записанные сессии. В TLS 1.3 forward secrecy обязательна (DH-only cipher suites).
- CertificateVerify: сервер подписывает данные handshake’а своим приватным ключом. Клиент проверяет подпись публичным ключом из сертификата. Так клиент убеждается, что разговаривает именно с владельцем сертификата, а не с MITM.
В TLS 1.2 handshake был сложнее — 2 RTT, отдельный ChangeCipherSpec. В TLS 1.3 убрали много legacy: только AEAD-cipher’ы (AES-GCM, ChaCha20-Poly1305), только DH, только сильные хеши.
Сертификаты: цепочка доверия
Сертификаты и PKI: кто кому доверяет в интернетеСертификат — это документ в формате X.509, в котором написано:
- Кому принадлежит (subject):
CN=api.github.com,O=GitHub, Inc. - Кто выдал (issuer):
CN=DigiCert TLS Hybrid ECC SHA384 2020 CA1. - Публичный ключ сервера.
- Срок действия (notBefore, notAfter).
- Subject Alternative Names (SAN): список доменов, для которых валиден (
api.github.com,*.github.com). - Подпись CA — гарантия, что CA подтверждает, что владелец публичного ключа — это действительно владелец домена.
Один сертификат сам по себе ничего не доказывает. Нужна цепочка доверия (chain of trust).
Сервер -> промежуточный CA -> корневой CA. Корневой CA лежит в root store клиента
Когда клиент получает leaf-сертификат, он:
- Читает поле issuer -> ищет в цепочке (которую обычно прислал сам сервер) промежуточный CA.
- Проверяет подпись leaf-сертификата публичным ключом промежуточного CA.
- Читает issuer промежуточного -> находит корневой CA в локальном root store.
- Проверяет подпись промежуточного корневым.
- Если всё сходится — цепочка доверия валидна.
Root store — это набор предустановленных корневых сертификатов. У каждой системы свой:
- macOS / iOS: System Keychain.
- Windows: Certificate Manager (
certmgr.msc). - Linux:
/etc/ssl/certs/(как правило, ca-certificates package). - Firefox: свой root store, не системный.
- Python (requests / httpx): пакет
certifi(берёт root store из Mozilla NSS).
certifi обновляется отдельно от системы. Если вы не обновляли Python-пакеты полгода, у вас старый root store. Если в это время Mozilla отозвала какой-то скомпрометированный CA, ваш requests всё ещё будет ему доверять. Команда pip install -U certifi периодически — хорошая привычка.
Что именно проверяет клиент
Помимо подписи в цепочке, клиент проверяет несколько других вещей. Если хоть одна проверка падает — TLS handshake fails.
Если что-то не сходится — клиент по идее должен прервать соединение. Но… не все клиенты это делают одинаково строго.
Как curl и Python проверяют сертификаты
Посмотрим на конкретных инструментах. Сравним поведение и опции.
# curl: по умолчанию проверяет всё
curl https://api.github.com/users/octocat
# Если что-то не так:
# curl: (60) SSL certificate problem: certificate has expired
# Verbose handshake
curl -v https://api.github.com 2>&1 | grep -E "TLS|SSL|certificate"
# * SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256
# * Server certificate:
# * subject: CN=*.github.com
# * start date: Mar 6 00:00:00 2025 GMT
# * expire date: Mar 7 23:59:59 2026 GMT
# * issuer: C=US; O=DigiCert Inc; CN=DigiCert Global G2 TLS RSA SHA256 2020 CA1
# * SSL certificate verify ok.
# Опасный флаг: отключение verify
curl -k https://expired.badssl.com/ # -k = --insecure
# Соединится, но это плохо
# requests: по умолчанию verify=True (через certifi)
import requests
r = requests.get("https://api.github.com/users/octocat")
print(r.status_code) # 200
# Опасный вариант -- отключение verify
r = requests.get("https://api.github.com", verify=False)
# Сработает + warning: InsecureRequestWarning
# Правильный вариант -- указать свой CA-bundle (для корп-CA)
r = requests.get("https://internal.corp.local", verify="/etc/ssl/corp-ca.pem")
# httpx (0.28+): то же самое, но другое API
import httpx
client = httpx.Client(verify="/etc/ssl/corp-ca.pem")
r = client.get("https://internal.corp.local")
Что происходит «под капотом» в Python:
requestsиспользуетurllib3, который используетsslмодуль stdlib, который использует OpenSSL (системный или встроенный в Python).- Root store берётся из
certifi.where()— путь к pem-bundle, который ставится с пакетомcertifi. - При
verify=Falseurllib3отключает проверку черезssl.CERT_NONE. Клиент примет ЛЮБОЙ сертификат, в том числе самоподписанный от любого MITM.
Почему verify=False — катастрофа
Это самый частый антипаттерн в junior-коде. Кажется: «у меня тестовая среда, серт самоподписанный, выключу проверку — и поедем». Что в реальности происходит?
Без проверки сертификата клиент верит любому серверу
С verify=False вы:
- Отдаёте Bearer-токены, API-ключи, cookies — атакующему.
- Получаете данные, которые атакующий может изменить на лету.
- Не имеете никакой защиты от MITM. HTTPS превращается в неаутентифицированное шифрование.
Правильные альтернативы:
- Корпоративный CA: получите
corp-ca.pem, скормитеverify="/path/to/corp-ca.pem". - Самоподписанный серт для теста: сгенерируйте серт, положите в локальный bundle, укажите путь.
- Локалка / dev: используйте
mkcert— он создаёт локальный CA и добавляет его в root store.
verify=False допустимо ровно в одном случае: вы пишете diagnostics-скрипт для проверки expired/broken сертификатов на собственной инфраструктуре, и понимаете риск. В production-коде, в скриптах ETL, в anything-что-ходит-в-интернет — это бомба замедленного действия.
Когда сертификат внезапно перестаёт работать
Дебаг TLS на практике: openssl s_client, SSL Labs, типичные ошибкиВ практике data engineer вы столкнётесь с этими ошибками. Стоит знать заранее, что они значат.
| Ошибка | Причина | Что делать |
|---|---|---|
| CERTIFICATE_VERIFY_FAILED | Сертификат серверa не валиден (просрочен, не от доверенного CA, неверный hostname) | Проверьте curl -v https://... — он покажет конкретную причину |
| HOSTNAME_MISMATCH | Серт выдан на foo.com, а вы стучитесь на bar.com | Проверьте URL. Возможно, лоадбалансер обслуживает несколько доменов |
| CERTIFICATE_EXPIRED | notAfter < now | Свяжитесь с командой API. Серт нужно обновить |
| UNKNOWN_ISSUER | Корневой CA не в root store клиента | Для корп-CA: добавьте в verify=.... Для public — обновите certifi |
| SELF_SIGNED_CERT_IN_CHAIN | Промежуточный или leaf — self-signed | Скорее всего dev-окружение. Добавьте этот серт в bundle |
| OCSP_RESPONSE_UNAUTHORIZED | OCSP-сервер CA вернул, что не знает про этот серт | Редко. Часто временное у CA. Подождите |
Попробуй сам
Поиграйте с настоящими сертификатами и сравните вывод.
# 1. Посмотреть полный handshake
curl -v https://api.github.com 2>&1 | less
# 2. Достать сертификат сервера в pem
echo | openssl s_client -showcerts -servername api.github.com -connect api.github.com:443 2>/dev/null \
| openssl x509 -inform pem -noout -text | head -40
# Видно: subject, issuer, validity, SANs, fingerprint
# 3. Проверить срок до истечения
echo | openssl s_client -servername api.github.com -connect api.github.com:443 2>/dev/null \
| openssl x509 -noout -dates
# notBefore=Mar 6 00:00:00 2025 GMT
# notAfter=Mar 7 23:59:59 2026 GMT
# 4. Намеренно сломанные сайты (для тестов)
curl https://expired.badssl.com/ # CERTIFICATE_EXPIRED
curl https://self-signed.badssl.com/ # SELF_SIGNED_CERT_IN_CHAIN
curl https://wrong.host.badssl.com/ # HOSTNAME_MISMATCH
# 5. То же из Python
python3 -c "
import requests, certifi
print('certifi bundle path:', certifi.where())
print('---')
r = requests.get('https://api.github.com')
print('OK status:', r.status_code)
"
Дальше — попробуйте сами:
- Откройте
certifi.where()и посмотрите, сколько корневых CA в bundle (grep -c BEGIN /path/to/cacert.pem). Обычно 130-150. - Найдите там
DigiCert Global Root G2— это родитель цепочки для github.com. - Запустите свой
requests.get(url, verify=False), посмотрите warning. Поймите, почему его пишут.