Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 12 из 25 ~20 мин +20 XP

RSA и ECDSA: асимметричные подписи

RS256 — асимметричная подпись jwt в Go: приватный ключ выпускает токены, публичный проверяет их, и именно это снимает главный недостаток HMAC из [пятого урока](/courses/jwt-go/lessons/05-hmac-podpis/). Разбираем генерацию пары ключей, jwt.ParseRSAPrivateKeyFromPEM, SigningMethodRS256 в keyfunc, компактный ES256 и честные границы, за которыми HS256 всё ещё правильный выбор.

Содержание урока · 9 разделов

У HS256 есть неудобное свойство: тот же секрет, которым токены выпускаются, нужен и для их проверки. Как только проверяющих становится больше одного — второй сервис, воркер, мобильный бэкенд — каждый держит копию секрета, и каждый становится точкой, из которой можно выпускать токены. Утечка одного секретного конфига равна компрометации всей системы: подписать подделку нападающему мешает ровно ничего. RS256 — асимметричная подпись jwt в Go — переворачивает раскладку: приватный ключ существует в единственном экземпляре у выпускающего сервиса, а публичный можно раздавать всем проверяющим свободно, хоть публиковать на сайте. Подделать токен, владея только публичным ключом, нельзя: он умеет проверять подписи, но не создавать их. В этом уроке выпустим токен RS256, проверим его, разберём ES256 как компактную альтернативу и скажем, когда HS256 остаётся разумным.

Как устроена rsa подпись токена#

RSA-подпись строится на паре ключей, связанных математически: то, что подписано приватным, проверяется парным публичным. По алгоритму RS256 расшифровывается как RSASSA-PKCS1-v1_5 с SHA-256: подписывается не сам токен, а его хеш — конкатенация закодированных header и payload, тех же двух частей, из которых состоит строка до второй точки. Приватный ключ — файл вроде private.pem, который читает только выпускающий сервис; публичный — public.pem, лежащий у каждого проверяющего. ECDSA идёт тем же маршрутом с другими числами: ES256 использует эллиптическую кривую P-256, ключ занимает 256 бит вместо 2048 и больше у RSA, а подпись — около шестидесяти четырёх байт против двухсот пятидесяти шести у RSA-2048. Для REST-запросов разница незаметна, для устройств с узким каналом — заметна. В golang-jwt v5 семейства выглядят одинаково: меняются константа метода — SigningMethodRS256 или SigningMethodES256 — и тип ключа, который уходит в SignedString и возвращается из keyfunc.

Выпуск токена приватным ключом

GO
package auth

import (
        "crypto/rsa"
        "fmt"
        "os"
        "time"

        "github.com/golang-jwt/jwt/v5"
)

func loadPrivateKey() (*rsa.PrivateKey, error) {
        data, err := os.ReadFile("keys/private.pem")
        if err != nil {
                return nil, err
        }
        key, err := jwt.ParseRSAPrivateKeyFromPEM(data)
        if err != nil {
                return nil, fmt.Errorf("битый private.pem: %w", err)
        }
        return key, nil
}

func IssueRS256(userID int64, role string) (string, error) {
        key, err := loadPrivateKey()
        if err != nil {
                return "", err
        }
        claims := AppClaims{
                UserID: userID,
                Role:   role,
                RegisteredClaims: jwt.RegisteredClaims{
                        Issuer:    "gomaster",
                        ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
                },
        }
        return jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(key)
}

jwt.ParseRSAPrivateKeyFromPEM принимает PEM в формате PKCS1 или PKCS8 — оба выдают openssl — и возвращает готовую структуру rsa.PrivateKey. SignedString смотрит на тип ключа: с rsa.PrivateKey она подписывает RSA-схемой, соответствующей alg из header. Передать сюда байты PEM или строковый секрет нельзя — метод вернёт ошибку неверного типа ключа ещё до криптографии, и это встроенная защита от путаницы типов.

Проверка публичным ключом на стороне сервиса#

Проверяющей стороне нужен только публичный ключ — и это главный выигрыш схемы. Сервисы, которые лишь проверяют токены, получают public.pem при деплое и никогда не видят приватный: скомпрометировать выпуск, украв конфиг проверяющего, невозможно. Структура проверки та же, что в уроке про ParseWithClaims и keyfunc: утверждаем метод, возвращаем ключ подходящего типа, добавляем WithValidMethods. Меняются ровно две детали: константа метода — SigningMethodRS256 — и тип ключа в возврате keyfunc: для RSA это *rsa.PublicKey, структура с модулем и экспонентой, без приватной половины. Всё остальное — разбор строки, сверка подписи, автоматические exp и iss — работает как раньше.

VerifyRS256: keyfunc возвращает публичный ключ

GO
func VerifyRS256(tokenStr string, public *rsa.PublicKey) (*AppClaims, error) {
        claims := &AppClaims{}
        _, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (any, error) {
                if _, ok := t.Method.(*jwt.SigningMethodRS256); !ok {
                        return nil, fmt.Errorf("неожиданный alg: %v", t.Header["alg"])
                }
                return public, nil
        }, jwt.WithValidMethods([]string{"RS256"}))
        if err != nil {
                return nil, err
        }
        return claims, nil
}

Три замка работают вместе: утверждение t.Method отсекает чужие алгоритмы в keyfunc, WithValidMethods режет список на уровне парсера ещё до её вызова, а подпись сверяется публичным ключом. Ошибка любого замка — err не nil и токен не годен. Обратите внимание: в проверяющем коде нет ни одного секрета — его можно показывать и публиковать без риска для выпуска.

HS256, RS256 и ES256 рядом#

СвойствоHS256RS256ES256
Тип ключаобщий секрет []byteпара RSA 2048 бит и большепара на кривой P-256
Кто может проверятьдержатель секрета — он же подписываетлюбой с публичным ключомлюбой с публичным ключом
Скоростьсамая быстраямедленнее на порядокбыстрее RSA
Размер подписиоколо 43 байт256 байт при RSA-2048около 64 байт
Типичный сценарийодин монолит без соседейidentity-сервис и много проверяющихто же, но с экономией байт

Генерация пары и дисциплина хранения#

Пара генерируется openssl-ом один раз: openssl genrsa -out private.pem 2048 создаёт приватный ключ, openssl rsa -in private.pem -pubout -out public.pem выводит из него публичный. Нижняя граница для RSA сегодня — 2048 бит; если ключ живёт годами, берите 3072 или 4096 — цена выпуска в миллисекунды вас не волнует, он происходит на логине, а не в горячем цикле. Для ES256 команда другая: openssl ecparam -name prime256v1 -genkey -noout -out private.pem. Дальше дисциплина хранения. Приватный ключ не попадает в git ни в каком виде — ни в конфиге, ни в тестовой папке; он живёт в менеджере секретов или на томе с правами 0600, а путь приходит переменной окружения. Публичный ключ секретом не является: его кладут в репозиторий, раздают проверяющим или — правильнее — отдают по URL, чем займётся следующий урок про JWKS. Протухание пары планируется заранее: смена ключей без простоя — это ротация, у неё есть свой протокол, и руками его лучше не изобретать. Отдельно про резервную копию: приватный ключ без бэкапа означает выпустить новую пару и разом перелогинить всех пользователей, поэтому копия в менеджере секретов обязательна; а вот продублировать её в общий чат или на рабочий ноутбук — значит свести всю дисциплину хранения к нулю.

Почему HS256 всё ещё жив#

После сравнительной таблицы честно сказать обратное: асимметрия — не бесплатное улучшение, а плата за архитектуру, и там, где проверяющих нет, она не нужна. Один монолит, который и выпускает, и проверяет токены сам, живёт с HS256 спокойно: один секрет в переменной окружения, ни одного файла ключей, ни одного JWKS-эндпоинта, ни одного вопроса про ротацию между сервисами. Операционная простота — реальная ценность: меньше движущихся частей, меньше мест, где можно потерять ключ. Мой критерий перехода на RS256 — не мода, а появление второго проверяющего: воркер, который читает те же токены, отдельный identity-сервис, внешний партнёр. До этого момента смена подписи добавляет работы без выигрыша в безопасности: секрет всё равно один и живёт в одном месте. Переходный период планируют заранее: пока часть сервисов ещё живёт на HMAC, проверяющая сторона держит оба семейства в WithValidMethods и сверяет alg токена с его происхождением, а по завершении миграции белый список сжимается до одного значения — это первая строка чек-листа после переезда. Ошибкой я считаю оба края: тащить RSA в монолит ради солидности и наоборот — раздавать HMAC-секрет трём сервисам, потому что «RSA это сложно». Сложность там один раз, а утечка секрета — навсегда.

Ключи в тестах: генерировать, а не хранить#

Тесты не должны читать keys/private.pem из репозитория: во-первых, это приучает к ключам в git, во-вторых — общий ключ для тестов и прода рано или поздно переползёт из фикстуры в конфиг. В тестах пара генерируется на лету: rsa.GenerateKey(rand.Reader, 2048) занимает десятки миллисекунд на обычной машине и даёт уникальную пару на каждый прогон; для ES256 та же история — ecdsa.GenerateKey с elliptic.P256(). Проверяющая сторона теста берёт key.PublicKey — и весь круг выпуск-проверка работает без единого файла и без переменных окружения. Если скорость прогонов важна, ключ генерируется один раз в TestMain и переиспользуется всеми тестами пакета: уникальность между прогонами сохраняется, внутри прогона тоже. Отдельный плюс подхода — тесты перестают зависеть от содержимого PEM-файлов: подпорченная фикстура больше не может устроить мистическую ошибку подписи в пятницу вечером.

Проверка: тип ключа в keyfunc

Какой тип должна возвращать keyfunc для токена, подписанного RS256?

Проверка: неверный тип ключа при выпуске

Что вернёт SignedString, если передать ей []byte вместо *rsa.PrivateKey при выпуске RS256?

Токен с чужим алгоритмом

Прочитайте код и выберите, что он выведет:

// токен: header {"alg":"HS256"}, подписан неизвестно чем
tokenStr := "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI3In0.c2ln"

claims := &AppClaims{}
_, err := VerifyRS256(tokenStr, publicKey)
// что в err?

ES256: тот же сценарий тремя заменами

GO
key, err := jwt.ParseECPrivateKeyFromPEM(data)
// ...
claims := AppClaims{
        UserID: userID,
        Role:   role,
        RegisteredClaims: jwt.RegisteredClaims{
                ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
        },
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodES256, claims).SignedString(key)

Переход на ECDSA — три замены: ParseECPrivateKeyFromPEM читает ключ кривой, SigningMethodES256 задаёт семейство, а в keyfunc возвращается *ecdsa.PublicKey. Подпись ужимается примерно до шестидесяти четырёх байт против двухсот пятидесяти шести у RSA-2048, генерация подписи быстрее. Механика проверки, claims и сроки не меняются вообще.

Восстановите keyfunc проверки RS256

Допишите keyfunc: утверждение метода, проверка флага и возврат публичного ключа.

claims := &AppClaims{} _, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (any, error) { if _, ok := t.Method.(*jwt.); ! { return nil, fmt.Errorf("неожиданный alg: %v", t.Header["alg"]) } return , nil }, jwt.WithValidMethods([]string{"RS256"}))

Соберите выпуск RS256-токена

Расставьте строки выпуска в правильном порядке: от чтения файла ключа до возврата подписанной строки.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: асимметричные подписи

Термин

RS256

нажмите, чтобы перевернуть
Определение

Асимметричная подпись jwt: RSASSA-PKCS1-v1_5 с SHA-256. Приватный ключ выпускает, публичный проверяет; подпись RSA-2048 занимает 256 байт.

1 / 5

Роли из урока про RequireRole работают с RS256 без единой правки: проверка прав читает claims, ей всё равно, чем подписан payload. Главное изменение — организационное: выпуск и проверка разъезжаются по сервисам, и это вход в архитектуру из урока про JWT в микросервисах, где проверяющих много, а identity-сервис один. Каркас проверяющего сервиса — обычный HTTP-роутер, и его сборка по слоям разобрана в курсе по chi; для gin та же проверка собрана в уроке про JWT в gin. Остался последний ручной шаг — раздача публичного ключа: в следующем уроке про JWKS он уедет на URL, и проверяющие перестанут хранить файлы вовсе. Вернуться к HMAC и ParseWithClaims всегда можно на странице курса jwt-go.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • RS256 jwt в Go разделяет роли: приватный ключ выпускает, публичный проверяет, и он не секрет.
  • jwt.ParseRSAPrivateKeyFromPEM декодирует PKCS1 или PKCS8 в *rsa.PrivateKey; SignedString с ней подписывает RS256.
  • В keyfunc возвращается *rsa.PublicKey; утверждение метода плюс WithValidMethods закрывают путаницу алгоритмов.
  • ES256 — компактная альтернатива: ключ P-256, подпись около 64 байт, ParseECPrivateKeyFromPEM.
  • Приватный ключ не в git: менеджер секретов или том с правами 0600; публичный можно раздавать свободно.
  • HS256 остаётся разумным для одного монолита; асимметрия нужна, когда проверяющих несколько.

Домашнее задание

Сгенерируйте пару openssl-ом, выпустите токен IssueRS256 и проверьте его VerifyRS256. Затем скопируйте public.pem в отдельную папку, уберите приватный ключ из досягаемости и убедитесь, что проверка работает без него. Попробуйте SignedString с байтовым секретом вместо ключа и сохраните текст ошибки — он пригодится при отладке типов ключей.

Личные заметкиПусто
Хранятся только в вашем браузере (JWT · RSA и ECDSA: асимметричные подписи)

Вопросы и ответы по уроку

Насколько RS256 медленнее HS256?

Подпись RSA-2048 на типичном сервере занимает доли миллисекунды, HMAC — микросекунды; при выпуске на логине разница незаметна. Проверка RSA тоже укладывается в бюджет запроса, а её можно выносить на gateway. Замер на своём железе честнее любых общих цифр.

Можно ли проверять RS256-токен приватным ключом?

Технически да: из *rsa.PrivateKey берётся поле PublicKey, и проверка пройдёт. Но передавать приватный ключ всем проверяющим — значит терять главный выигрыш асимметрии; публичный ключ отдельно и есть правильный интерфейс проверки.

Что выбрать для нового проекта — RS256 или ES256?

Оба рабочие: ES256 даёт короткие подписи и быструю генерацию, RS256 — более старую поддержку инструментов и привычные 2048-битные ключи. Для REST API разница косметическая; если важны байты — ES256, если совместимость со старыми клиентами — RS256.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Telegram25 уроков~12 чновичок25 уроков уже доступны

Telegram-боты на Go: от эха до полноценного сервиса

Учимся писать Telegram-ботов на Go: токен BotFather, команды, клавиатуры, состояния диалога, медиа, рассылки, webhook и деплой. 25 уроков с кодом.

  • Создавать бота через BotFather и хранить токен безопасно
  • Получать обновления через long polling и webhook
  • Отвечать сообщениями, командами и клавиатурами
  • Строить многошаговые диалоги с сохранением состояния