Go 1.20 и RSA: что на самом деле замедлилось — и как ловить регрессии производительности

В Go 1.20 crypto/rsa перешёл на constant-time bigmod ради защиты от тайминговых атак — и просел по скорости. Разбираем на живой матрице версий, где просело сильнее (спойлер: подпись подорожала на 34–61%, а публичные проверка и шифрование — в 5–7 раз), почему, что это значит для JWT, и как поставить CI-гейт, который ловит такие регрессии до прода

Обновление Go редко попадает в разряд «сломало прод». Но Go 1.20 сделал это тихо и по учебнику. Инженеры, повсеместно использующие JWT на RS256, рассказывают: после апгрейда RSA стал заметно медленнее, и релиз пропускали, дожидаясь следующего. Это полевые наблюдения из нашего окружения — ниже мы воспроизводим эффект на стенде и уточняем, что именно замедлилось (спойлер: не то, что кажется). Причина не баг: crypto/rsa осознанно перевели на арифметику с постоянным временем, закрыв класс тайминговых атак ценой производительности. Release notes Go 1.20 честно предупреждали: расшифровка подорожала на «15–45%», а публичное шифрование — «approximately 20x slower… Performance is expected to improve in future releases».

История сама по себе — хорошая иллюстрация размена «безопасность против скорости». Но интереснее две вещи, которые всплывают, только если снять числа, а не пересказать твит: во-первых, замедлилась не та операция, на которую думаешь; во-вторых, поймать такую регрессию до прода — вопрос одной дисциплины, а не удачи. Про это и статья.

Приборная панель регрессии производительности по версиям Go: две кривые на логарифмической шкале — public/verify резко спайкует на версии 1.20 и постепенно восстанавливается, private/sign на этой шкале меняется куда слабее (тоже просела, но в разы меньше); переход алгоритма math/big (asm) на constant-time bigmod с пометкой timing-safe; CI-гейт со штампом FAIL на версии 1.20 и OK на остальных; эмблемы ключа RSA-2048 (e=65537) и токена JWT

В статье

Что случилось: math/big → bigmod

До Go 1.20 модульная арифметика RSA в crypto/rsa опиралась на math/big — большие числа с ассемблерными fast-path. Быстро, но такая арифметика не обязана быть constant-time: время возведения в степень может зависеть от битов секретного экспонента, а это тайминговая утечка о приватном ключе — классический класс атак на RSA (Kocher, 1996; Brumley–Boneh, 2003). Не путать с padding-оракулами Bleichenbacher/Marvin на расшифровке PKCS#1 v1.5: те восстанавливают открытый текст или сессионный секрет, а не сам ключ — это отдельная история.

Go 1.20 закрыл первый класс, переведя арифметику RSA на новый пакет crypto/internal/bigmod — реализацию модульной арифметики на чистом Go, работающую за постоянное время: поток управления и обращения к памяти не зависят от значений секретных битов (при заданной длине операнда). Это не «неоптимизированный код», а другой класс алгоритма — и он платит за безопасность бóльшей ценой на операцию.

Насколько бóльшим — release notes оценили в «~20x». Но 20× чего именно? Тут-то и начинается измерение.

Что просело на самом деле — измеряем

Стенд прогоняет одинаковый набор бенчмарков на шести версиях Go в Docker с фиксированными ключами (одни и те же RSA/ECDSA-ключи на всех версиях — иначе разброс ключа мешался бы с разбросом версии) и чередованием версий по раундам с ротацией порядка (8 раундов, в каждом список циклически сдвигается — чтобы тепловой дрейф распределялся между версиями, а не оседал на одной), а сравнивает через benchstat (доверительные интервалы и p-value). Весь код и матрица — в digital-cookbook/performance/crypto-rsa-regression. Медианы ns/op на одной машине (важны соотношения, не абсолют):

Операция Go 1.19 Go 1.20 Go 1.26
RSA-2048 verify (публ.) 49 мкс 246 мкс (+405%) 39 мкс
RSA-2048 encrypt (публ.) 51 мкс 261 мкс (+409%) 40 мкс
RSA-4096 verify (публ.) 146 мкс 1000 мкс (+586%) 540 мкс
RSA-4096 encrypt (публ.) 143 мкс 1006 мкс (+602%) 542 мкс
RSA-2048 sign (прив.) 1.42 мс 1.91 мс (+34%) 1.35 мс
RSA-4096 sign (прив.) 8.01 мс 12.89 мс (+61%) 7.92 мс
RS256 round-trip (sign+verify) 1.47 мс 2.21 мс (+50%) 1.37 мс
ECDSA-P256 sign 25.0 мкс 24.8 мкс 35.1 мкс
ECDSA-P256 verify 75.4 мкс 73.2 мкс 73.9 мкс
HMAC-SHA256 1490 нс 1422 нс 837 нс

Все различия по RSA на переходе 1.19→1.20 статистически значимы (p ≤ 0.001 по benchstat). Просели обе стороны RSA, но резко несимметрично: публичные проверка и шифрование — в 5–7 раз (+405…+602%), приватная подпись — на +34% (2048) и +61% (4096). В измеренном наборе крупную регрессию показал только RSA: ECDSA P-256 и HMAC значимо не изменились (p > 0.4). Оговорка про охват: мы мерили только P-256, а release notes Go 1.20 отдельно отмечают рост CPU у ECDSA на 5–30% преимущественно для P-384/P-521 — эти кривые в стенде не проверялись. Это и есть та «encryption ~20×» из release notes: сильнее всего пострадали публичные операции (магнитуда host-зависима, у нас скромнее). Картина ломает ходовую интуицию: «медленная» операция RSA — это подпись (1.4 мс против 49 мкс у проверки), и кажется логичным, что просядет она; а в разы просела самая дешёвая — публичная.

Отдельно — про то, как эти числа получены, потому что первый заход их не дал. Когда версии гонялись подряд (все повторы одной, потом следующей), разброс по подписи доходил до ±60–70%, а разница 1.19→1.20 выходила статистически незначимой (p ≈ 0.1) — по такому замеру цифру заявлять было нельзя. Помогла смена протокола: раунды с ротацией порядка версий, чтобы ни одна не занимала всегда один и тот же тепловой слот. После этого разброс на baseline упал до ±4–18%, и просадка подписи стала значимой. Мораль по ходу: прежде чем объявлять «эффекта нет», проверьте, не съедает ли его ваш собственный протокол замера.

Для контекста первоисточника: вилку «+15–45%» release notes Go 1.20 дают для расшифровки (родственная приватная операция), а не для подписи напрямую; release notes Go 1.21 подтверждают, что в 1.20 регрессировали и расшифровка, и подпись. Наш RS256 round-trip (+50%) измеряет sign+verify вместе — это не «величина подписи», а комбинированная операция.

Почему просели именно публичные операции

Парадокс: constant-time нужен ради защиты приватного ключа, а сильнее просел публичный путь. Разгадка — в относительной цене нового алгоритма.

Публичная операция RSA — это возведение в степень с маленьким открытым экспонентом (обычно 65537 — 17 бит, всего два из них единицы). Она дёшева: на 1.19 проверка RSA-2048 стоила десятки микросекунд. Приватная операция возводит в степень с полноразмерным секретным экспонентом (порядка длины модуля) — она дорога сама по себе, её время определяет огромное модульное возведение в миллисекунды.

Оговоримся сразу: bigmod не «считает любой экспонент одинаково» — его возведение в степень проходит по всем байтам экспонента, так что работа растёт с длиной (constant-time означает независимость от значений битов при данной длине, а не равную цену публичной и приватной операции). Что видно прямо в исходнике (Go 1.20): публичная операция (encrypt/verify) строит bigmod.Modulus на каждом вызове, тогда как приватный путь модуль кеширует (Precompute). Но это не вся история: само constant-time возведение — оконный проход и предподсчёт таблицы степеней внутри bigmod.Exp — платится на каждом вызове, в том числе приватном; так что «per-call надбавки нет» про приватный путь было бы неверно.

Инженерный вывод поверх этих фактов — про относительную цену. Публичная операция изначально дёшева (короткий экспонент 65537), и общая стоимость нового алгоритма (построение модуля + оконный проход + таблица) для неё огромна на фоне её крошечной работы. У приватной операции та же стоимость тонет в миллисекундах основного modexp с полноразмерным секретным экспонентом. Отсюда кратная просадка именно проверки и шифрования: замедлилась не самая дорогая операция, а самая дешёвая, у которой накладные расходы стали сопоставимы с ней самой.

Любопытный побочный эффект того же перехода виден в аллокациях: RSA-2048-подпись на 1.19 делала 108 аллокаций на операцию, на 1.26 — 2 (у RSA-4096 — 122 → 53). bigmod куда экономнее по памяти. То есть размен не сводится к «стало медленнее» — по одной оси стало хуже, по другой лучше.

Что это значит для JWT

Для RS256 (RSA + SHA-256) карта боли теперь читается точно:

  • Выпуск токена = rsa.SignPKCS1v15 (приватный ключ). Это делает auth-сервер; операция подорожала на +34% (RSA-2048) — заметно, но на порядок меньше проверки.
  • Проверка токена = публичная операция RSA. Там, где токены валидируют локально (по публичному ключу на самом сервисе, а не через интроспекцию у auth-сервера), проверка идёт на каждом входящем запросе — и именно она подорожала в разы.

Вот почему система с локальной проверкой JWT замечает регрессию Go 1.20 мгновенно: горячий путь — не редкий выпуск токенов, а массовая верификация, и удар пришёлся ровно туда. golang-jwt здесь ни при чём как «виновник» — под капотом он зовёт те же crypto/rsa-примитивы. А ES256 (ECDSA) и HS256 (HMAC) регрессии сопоставимого масштаба не показали (HMAC на 1.19→1.20 даже чуть быстрее, ECDSA в пределах шума) — если больно, то на RS*/PS*.

Восстановление — и его пределы

«Ждать следующего релиза» сработало не мгновенно и не полностью. По матрице:

  • RSA-2048 verify: 1.20 — 246 мкс, 1.21 — 140 мкс (уже лучше, но всё ещё ×3 к 1.19), с 1.23 — ~36 мкс, то есть быстрее, чем до регрессии.
  • RSA-4096 verify: 1.20 — 1000 мкс, 1.21 — даже 1014 мкс (хуже!), 1.23 — 481 мкс, 1.26 — 540 мкс. Это всё ещё +271% к 1.19 — до конца не восстановлено по сей день.

Мораль восстановления двойная: во-первых, «в следующем релизе починят» — не гарантия и не одномоментность (для 2048 хватило 1.23, для 4096 — нет); во-вторых, это ровно тот случай, когда шаг за шагом по версиям надо мерить, а не верить release notes на слово, что «стало лучше».

Как ловить деградацию производительности

Регрессию Go 1.20 поймал бы кто угодно с бенч-гейтом в CI — до прода, а не по жалобам на латентность. Что для этого нужно.

  • Микробенчмарки на горячих путях. testing.B на криптографии, сериализации, хот-функциях. Меряем не «на глаз»: -count=N плюс benchstat, который даёт доверительные интервалы и p-value, а не сравнение двух одиночных ns/op. Одиночное число врёт шумом.
  • CI-гейт. Сравнивать бенчмарки нового кода (или новой версии тулчейна) с базой и ронять сборку при регрессии выше порога. В стенде это regression-gate.sh (порог 50%): на переходе 1.19→1.20 он падает сразу на шести бенчмарках — публичные verify/encrypt (+405…+602%), RSA-4096 sign (+61%) и RS256 round-trip (+50%), — а на 1.21→1.26 проходит. Тот самый гейт, которого не хватило. Порог стоит выбирать под свой SLA: значимая, но «подпороговая» регрессия (как +34% у RSA-2048 sign) иначе пройдёт незамеченной.
  • Апгрейд легаси — по шагам. Не «прыжок» через пять версий разом, а версия за версией с прогоном гейта: иначе при регрессии непонятно, какой шаг виноват.
  • Канарейка. Катить новую версию рантайма на часть трафика и смотреть p99, CPU, латентность — не только средние, но и хвосты.
  • Release notes — часть релиз-процесса. «approximately 20x slower» было написано прямым текстом. Чтение примечаний к релизу — не факультатив.
  • Что мерить. Не только ns/op, но и allocs/op, B/op, p99 под нагрузкой, CPU. И помнить про ловушки замеров: тепловой троттлинг, шумные соседи, разогрев, -benchtime, стабильность окружения. benchstat — именно чтобы отделить сигнал от шума; на нашей матрице тонкую кривую восстановления без него читать нельзя.

Мораль

Размен «безопасность против скорости» в криптографии — это инженерное решение, а не деградация по недосмотру: constant-time закрыл реальный класс атак, и авторы предупредили о цене. Правильный вывод из истории — не «избегайте обновлений», а «имейте бенч-гейт». Тогда такой размен виден в pull request и в матрице версий, а не в проде через выросшую JWT-латентность. А заодно — не всё, что «замедлилось», замедлилось там, где кажется: подпись просела на 34–61%, но публичная проверка — примерно на порядок сильнее. И это видно только из чисел, снятых по аккуратному протоколу: тот же эксперимент без ротации версий просто не разрешил просадку подписи. Умение отличить сигнал от собственного шума — и есть главный практический навык здесь.

Что дальше

Микробенчмарки — верхушка; под нагрузкой картину дают нагрузочное тестирование и хаос и перцентили хвостовой латентности, а профили — профилирование по языкам и родственная модель памяти по языкам. Про сам RSA/JWT в контексте авторизации будет отдельная серия. Если хотите, чтобы мы разобрали конкретный сборщик бенчмарков или CI-гейт под ваш стек, — напишите в Telegram-группе.

Обсуждение в Telegram

Присоединиться →

Комментарии