open atlas
↑ К треку
Node.js с нуля до senior NODE · 11 · 01

TLS и HTTPS в Node: отдавайте всю цепочку

TLS в Node держится на том, что вы отдаёте полную цепочку сертификатов, а не только листовой. Разберём handshake, SNI, возобновление сессии, secureContext и реальные ошибки сертификатов — и почему чинят цепочку, а не отключают проверку.

NODE Middle ◷ 16 min
Уровень
ОсновыJuniorMiddleSenior
Уже знаешь этот юнит? Пройди быструю проверку за минуту →

Сертификат открывается в Chrome — значит, можно деплоить. А потом бэкенд-сервис начинает падать с UNABLE_TO_VERIFY_LEAF_SIGNATURE на каждом исходящем fetch к вашему же API, и curl тоже не работает. Срок не вышел, имя хоста совпадает, приватный ключ верный. Баг: сервер отдаёт только листовой сертификат. Браузеры это маскируют — они кешируют промежуточные с других сайтов и докачивают недостающее (AIA-fetching). Node так не умеет. У него есть только системное хранилище корней и то, что пришло по проводу, а выпускающий промежуточный сертификат отсутствует — поэтому цепочку до доверенного корня собрать невозможно. Чинится это одной строкой в файле сертификата, а не флагом, отключающим проверку.

TLS-сервер в Node и что он на самом деле отдаёт

https.createServer — тонкая обёртка над tls.createServer: вы передаёте приватный ключ и сертификат, он терминирует TLS, и ваш обработчик запросов работает поверх расшифрованного потока. Самое важное поле — cert, и оно должно содержать полную цепочку, а не только собственный (листовой) сертификат сервера.

import { readFileSync } from "node:fs";
import { createServer } from "node:https";

const server = createServer(
  {
    key: readFileSync("privkey.pem"),
    // fullchain.pem = листовой сертификат, ЗА КОТОРЫМ идут все промежуточные, по порядку.
    // Это TLS-баг номер один в проде: вместо этого отдают cert.pem (только лист).
    cert: readFileSync("fullchain.pem"),
  },
  (req, res) => {
    res.writeHead(200);
    res.end("hello over TLS\n");
  },
);
server.listen(443);

Сертификат — это документ X.509 (стандарт формата цифровых сертификатов), связывающий открытый ключ с набором имён (Subject Alternative Names, или SAN — список разрешённых имён хостов) и подписанный издателем. Ваш лист подписан промежуточным CA, который подписан корневым CA, живущим в хранилище доверия клиента. Проверка — это движение клиента вверх по цепочке, пока он не дойдёт до уже доверенного корня. Клиент изначально доверяет только корням — промежуточного у него нет. Поэтому, если сервер отдаёт лишь лист, клиент не сможет соединить лист → корень, и проверка провалится. fullchain.pem от Let’s Encrypt существует именно для этого: это лист, склеенный с промежуточным(и). Используйте его, а не cert.pem.

Handshake: как согласовать ключ без утечки

До того как пойдёт первый байт HTTP, клиент и сервер выполняют TLS handshake: согласуют параметры, подтверждают личность сервера его сертификатом и выводят общий симметричный ключ. TLS 1.3 свёл это к одному раунду (1-RTT), потому что клиент угадывает группу обмена ключами и шлёт свою долю ключа в самом первом сообщении — так сервер может ответить всем необходимым, чтобы начать шифрование.

Клиент проверяет цепочку сертификатов (подписи валидны вплоть до доверенного корня, срок не вышел, а запрошенное имя хоста есть в SAN листа), сверяет CertificateVerify, чтобы убедиться, что сервер владеет соответствующим приватным ключом, и затем обе стороны переходят на симметричное шифрование. Асимметричная криптография аутентифицирует и обменивает ключ; основной трафик идёт быстрым симметричным (AES-GCM или ChaCha20). TLS 1.2 требовал двух раундов; если можно требовать 1.3 — handshake ощутимо дешевле.

SNI: много сертификатов на одном сокете

Один IP и порт часто обслуживают много имён хостов. Клиент сообщает серверу, какое имя ему нужно, в расширении SNI (Server Name Indication — указание имени сервера) внутри ClientHello — открытым текстом, до выбора сертификата, — чтобы сервер предъявил нужный сертификат. В Node вы выбираете сертификат по имени через SNICallback, возвращая SecureContext, собранный под каждое имя хоста:

import tls from "node:tls";
import { readFileSync } from "node:fs";
import { createServer } from "node:https";

const contexts = {
  "a.example.com": tls.createSecureContext({
    key: readFileSync("a.key"), cert: readFileSync("a.fullchain.pem"),
  }),
  "b.example.com": tls.createSecureContext({
    key: readFileSync("b.key"), cert: readFileSync("b.fullchain.pem"),
  }),
};

createServer({
  SNICallback(servername, cb) {
    const ctx = contexts[servername];
    cb(ctx ? null : new Error("unknown host"), ctx);
  },
}, handler).listen(443);

tls.createSecureContext — это переиспользуемый набор ключа, цепочки, шифров и настроек протокола; SNICallback позволяет выбрать один из них во время handshake. Так обратный прокси или мультиарендный сервер размещает десятки доменов на одном слушателе.

Возобновление сессии: пропустить дорогую часть

Полный handshake стоит асимметричной криптографии и раунда. Возобновление (resumption) позволяет вернувшемуся клиенту переиспользовать ранее согласованный секрет и пропустить большую часть этого. Два механизма: session ID — сервер хранит состояние сессии по идентификатору, а клиент предъявляет ID для возобновления (память на стороне сервера, без общего хранилища не масштабируется на кластер); и session ticket (RFC 5077) — сервер шифрует состояние сессии в непрозрачный блоб, который хранит и переотправляет клиент, без состояния на сервере, поэтому масштабируется горизонтально. В TLS 1.3 возобновление использует pre-shared keys, выведенные из тикетов, и может нести 0-RTT early data. Возобновление превращает многосообщенческий асимметричный handshake в быстрый сокращённый, снижая задержку соединения для повторных визитов.

Почему это работает

Сеньорский рефлекс при ошибке сертификата — никогда rejectUnauthorized: false (и не NODE_TLS_REJECT_UNAUTHORIZED=0). Это ничего не чинит — это отключает саму суть TLS-аутентификации, оставляя соединение настежь открытым для man-in-the-middle, который может предъявить любой сертификат. Шифрование без проверенной личности — это шифрование к атакующему. У каждой ошибки сертификата есть реальная, чинимая причина: отсутствующий промежуточный (почините цепочку), внутренний CA, неизвестный клиенту (добавьте его в доверие клиента через ca: или NODE_EXTRA_CA_CERTS), просроченный сертификат (перевыпустите), или несовпадение имени хоста (перевыпустите с правильным SAN). Отключение проверки просто прячет баг до момента, когда он ударит в проде.

Ошибки сертификатов, с которыми вы реально столкнётесь

Эти четыре дают большинство реальных тикетов по TLS. Константа подсказывает причину:

ОшибкаРеальная причинаФикс
UNABLE_TO_VERIFY_LEAF_SIGNATUREСервер отдал лист, но пропустил промежуточный; клиент не может построить путь до корня.Отдавайте fullchain.pem (лист + промежуточные), а не cert.pem.
DEPTH_ZERO_SELF_SIGNED_CERTСамоподписанный сертификат без цепочки к доверенному CA (частое в dev / внутренних сервисах).Добавьте сертификат/CA в доверие клиента: опция ca: или NODE_EXTRA_CA_CERTS. Не отключайте проверку.
CERT_HAS_EXPIREDЛист (или промежуточный) вышел за notAfter, либо часы клиента сбиты.Перевыпустите/автоматизируйте сертификат; проверьте NTP на клиенте.
ERR_TLS_CERT_ALTNAME_INVALIDИмя хоста, к которому вы подключились, отсутствует в списке SAN (CN современные клиенты игнорируют).Перевыпустите сертификат с верным SAN; подключайтесь по имени, которое реально есть в сертификате.

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

Викторина

Сертификат прекрасно грузится в браузере, но Node fetch падает с UNABLE_TO_VERIFY_LEAF_SIGNATURE против того же сервера. Самая вероятная причина?

Выбери лучший вариант

Node-клиент подключается к внутреннему сервису, чей сертификат подписан приватным CA вашей компании, и проверка падает. Выберите верный фикс.

Вспомните перед уходом
  1. 01
    Почему сертификат, работающий в браузере, падает с UNABLE_TO_VERIFY_LEAF_SIGNATURE в Node, и как это починить?
  2. 02
    Сравните session ID и session ticket для возобновления TLS и объясните, почему тикеты лучше масштабируются.
Итог

TLS в Node начинается с https.createServer (обёртка над tls.createServer), принимающего ключ и сертификат — и этот сертификат должен быть полной цепочкой, лист плюс промежуточные, потому что клиенты доверяют только корням и обязаны подняться по цепочке до одного из них. Отдавать один лист — классический баг прода за UNABLE_TO_VERIFY_LEAF_SIGNATURE; отдавайте fullchain.pem. Handshake обменивает ключ и аутентифицирует сервер этой цепочкой, а TLS 1.3 делает это за один раунд, отправляя долю ключа клиента заранее. SNI позволяет одному сокету обслуживать много имён хостов, а SNICallback возвращает SecureContext под каждое имя, собранный через tls.createSecureContext. Возобновление сессии — серверные session ID или бессессионные клиентские session ticket, масштабирующиеся на кластер, — пропускает дорогую часть handshake для повторных клиентов. И каждая ошибка сертификата (DEPTH_ZERO_SELF_SIGNED_CERT, CERT_HAS_EXPIRED, ERR_TLS_CERT_ALTNAME_INVALID) называет реальный дефект: почините цепочку, хранилище доверия, срок или SAN. Никогда не тянитесь к rejectUnauthorized: false — шифрование без проверенной личности — это шифрование прямо к атакующему. Теперь, когда встретите UNABLE_TO_VERIFY_LEAF_SIGNATURE в проде, первый вопрос — «мы отдали fullchain.pem?», а не «как отключить проверку?»

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.