Лицензионный ключ на 202 символа: почему не Ed25519 и не RSA

от автора

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

Дальше я сделал ровно то, что сделал бы наверное почти каждый: пошёл за Ed25519. Он компактный, быстрый, детерминированный, лежит в каждом втором SSH-ключе. В стандартной библиотеке .NET его не оказалось: ни в восьмой версии, ни в десятой. Зато в десятой уже есть постквантовые ML-DSA и SLH-DSA.

Дальше:

  • что показали замеры;

  • почему я в итоге выбрал самый медленный по проверке алгоритм из трёх;

  • почему не жалею об этом.

Что вообще требовалось от подписи?

Задача кажется academic только на первый взгляд. Требования диктовала не криптография, а форма продукта.

Требование

Откуда взялось

Проверка без сети

лицензия должна работать на компьютере без интернета

Ноль сторонних зависимостей

проверку лицензии хочу собрать из того, что уже есть в платформе

Ключ не должен превращаться в простыню

его вставляют руками, из письма

Ротация ключей

если приватный ключ утечёт, нужно уметь выпустить новый, не ломая старые лицензии

Проверка не тормозит запуск

она происходит не один раз за сеанс

Последний пункт стоит пояснить, потому что он не так очевиден. Я сознательно не стал делать в программе единый флаг «пользователь оплатил»: такой флаг снимается одним хак патчем в памяти. Для защиты от этого подпись перепроверяется в каждой точке, где платная функция реально исполняется. То есть проверок за сеанс не одна, а десятки, и вопрос «сколько стоит одна проверка» превращается из теоретического в практический.

Идёшь за Ed25519 — и упираешься в рантайм

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

Факт-чек: что рантайм выставляет наружу
var asm = typeof(ECDsa).Assembly;Console.WriteLine(asm.GetName().Name + " " + asm.GetName().Version);// какие асимметричные примитивы вообще доступныforeach (var t in asm.GetExportedTypes())if (t.IsAbstract && typeof(AsymmetricAlgorithm).IsAssignableFrom(t))Console.WriteLine(t.Name);// какие кривые библиотека знает по имениforeach (var p in typeof(ECCurve.NamedCurves).GetProperties())Console.WriteLine(p.Name);

Результат одинаково скучный и одинаково показательный в обеих версиях.

Что искали

.NET 8.0.28

.NET 10.0.9

Типы со словом «25519»

нет ни одного

нет ни одного

Асимметричные примитивы

DSA, ECAlgorithm, ECDiffieHellman, ECDsa, RSA

ровно те же

Именованные кривые

nistP256, nistP384, nistP521 и 14 brainpool

ровно те же

Постквантовая подпись

нет

ML-DSA, SLH-DSA, ML-KEM, композитные

Попытка получить кривую по имени вежливо объясняет, что мне здесь не рады. Формулировка, кстати, лукавая: дело не в «этой платформе», а в том, что такого примитива нет в API вообще.

Ответ на попытку создать Ed25519
ECDsa.Create(ECCurve.CreateFromFriendlyName("Ed25519"))→ PlatformNotSupportedException: The specified curve ‘Ed25519’or its parameters are not valid for this platform.

Дальше я полез в трекер рантайма — и там оказалось интереснее, чем в коде. Предложение добавить Ed25519 и Curve25519 создано 19 июня 2015 года. Актуальное предложение API висит с 28 декабря 2021 года, у него 53 комментария, метка api-approved и веха 11.0.0. То есть API согласован, но появится он в одиннадцатой версии, а мне подписывать ключи нужно было в восьмой.

Вот это поворот: подпись, устойчивую к квантовому компьютеру, в рантайм завезли раньше, чем ту, которой пятнадцать лет и которая лежит у половины читателей в ~/.ssh!

Что сложного: «просто возьми пакет»?

Очевидное возражение: возьми библиотеку и не занимайся ерундой. Я и попробовал встроенный и наиболее распространённые.

Вариант

Что приезжает вместе с ним

Встроенный ECDSA

ноль байт, он уже в рантайме

BouncyCastle 2.6.2

одна управляемая сборка 4,70 МБ, работает везде одинаково

NSec 24.4.0

обёртка 92 КБ плюс нативная libsodium под каждую платформу: 10 файлов, суммарно 4,08 МБ

С NSec выяснилась деталь, которая мне “понравилась” даже больше цифр. Свежая версия пакета отказалась ставиться в проект на .NET 8:

Ответ NuGet на попытку взять последнюю версию
error NU1202: Пакет NSec.Cryptography 26.4.0 несовместим с net8.0.Пакет NSec.Cryptography 26.4.0 поддерживает: net9.0, net9.0-ios18.0, ...  

Ничего криминального, обычная жизнь библиотеки. Но это ровно то, о чём стоит подумать заранее: у зависимости свой график, и он не совпадает с вашим. Сегодня вы на восьмёрке и всё хорошо, завтра нужен фикс, а фикс вышел, например только для девятки.

З.Ы. по Фрейду. Мой дистрибутив весит 63,7 МБ, потому что собран со встроенным рантаймом. На этом фоне лишние 4,7 МБ — это 7 %, и логически возникающий аргумент против: «раздувает дистрибутив» не применим в данной ситуации. Почему? Встроенная криптография в дистрибутиве уже лежит на машине пользователя как часть рантайма: взяв её, я не добавляю в проверку лицензии ни одного нового и не понятного “участника”. Сторонний пакет — это, наоборот, ещё один объект, за которым надо следить: обновлять, смотреть его уязвимости, помнить про его собственный график поддержки платформ (что мы только что и увидели) и проверять, как он переживёт планируемую обфускацию — сторонние сборки на ней спотыкаются охотнее. Мне не хотелось бы однажды разбираться, почему платный уровень перестал включаться после обновления чужой библиотеки.

Замеры: кто быстрее?

Хватит теории и скучной лирики — берём и меряем. Я взял четыре варианта и прогнал на своей рабочей машине: AMD Ryzen 7 5800H, Windows 10, .NET 8.0.28, сборка Release. Полезная нагрузка — 39 байт, ровно столько в моём формате ключа. Прогрев 2 000 итераций, замер 20 000, лучший результат из пяти прогонов.

Как мерил (сокращённо)
var payload = RandomNumberGenerator.GetBytes(39);using var ec = ECDsa.Create(ECCurve.NamedCurves.nistP256);var sig = ec.SignData(payload, HashAlgorithmName.SHA256);for (int i = 0; i < 2000; i++)              // прогрев JITec.VerifyData(payload, sig, HashAlgorithmName.SHA256);var sw = Stopwatch.StartNew();for (int i = 0; i < 20000; i++)ec.VerifyData(payload, sig, HashAlgorithmName.SHA256);sw.Stop();Console.WriteLine(sw.Elapsed.TotalMilliseconds / 20000 + " мс на проверку");

Алгоритм

Подпись

Проверка

Размер подписи

Публичный ключ

ECDSA P-256, встроенный

187 мкс

191 мкс

64 Б

64 Б

RSA-2048, встроенный

505 мкс

16 мкс

256 Б

294 Б

Ed25519, BouncyCastle

36 мкс

77 мкс

64 Б

32 Б

Ed25519, NSec + libsodium

34 мкс

91 мкс

64 Б

32 Б

Смотрим на колонку «проверка» — и видим, что выбранный мной вариант ECDSA P-256 проиграл обоим. Ed25519 проверяет в 2,5 раза быстрее, RSA-2048 — в двенадцать раз быстрее, потому что проверка у RSA это возведение в маленькую открытую экспоненту. Подписывает RSA, наоборот, медленнее, но подпись происходит в моем случае один раз на лицензию, по этому на эту разницу в общем то можно и не смотреть.

И тут же цифра, которая ставит всю таблицу на место. Первая криптографическая операция в свежем процессе (создание ключа и подпись) занимает 6,81 мс: в тридцать пять раз дольше «горячей» проверки. То есть всё, что я могу выиграть выбором алгоритма, тонет в разовой инициализации при запуске. Разница между 191 и 77 микросекундами — это разница между «незаметно» и «незаметно».

Почему выбор решала длина строки, а не микросекунды

А теперь то, что и определило выбор способа кодировки. Ключ живёт в письме, его копируют, а иногда перепечатывают руками. Кодирую в Base32, а не в более компактный Base64, намеренно: в алфавите Base32 нет строчных букв и нет пар-двойников вроде 0 и O, 1 и l, так что ключ можно продиктовать по телефону и не получить в ответ: «не подходит». Считаем: 39 байт данных плюс подпись, группы по пять символов через дефис.

Алгоритм

Подпись

Всего байт

Base32

Готовый ключ

ECDSA P-256 или Ed25519

64 Б

103

165

202 символа

RSA-2048

256 Б

295

472

571 символ

RSA-3072

384 Б

423

677

817 символов

Пятьсот семьдесят один символ — это уже не лицензионный ключ, это простыня на пол-экрана! Значит, RSA отпадает не по производительности, по которой он как раз хорош, а по внешнему виду письма. Остаются два варианта с подписью в 64 байта: тот, которого в рантайме нет, и тот, который есть.

Сразу оговорюсь, чтобы не выдавать нужду за добродетель: 202 символа — это тоже много. Красивого ключа вида ABCD-1234-EFGH здесь не будет ни при каком выборе алгоритма и вот почему. В оффлайновой схеме ключ обязан нести в себе всё: и данные лицензии, и подпись, которой эти данные скреплены, попросту программе больше неоткуда взять правду, у неё нет ни базы, ни сети. Подпись меньше 64 байт при вменяемой стойкости не бывает, а значит нижняя граница длины задана математикой. Короткие ключи из рекламных буклетов живут в другой архитектуре: там строка — просто номер, который сервер сверяет со своей базой, и без интернета такой ключ не значит ничего. Я сознательно выбрал длинную строку и работу без сети вместо короткой строки и обязательного подключения. Так что 202 символа — это не «коротко», это наименее длинно из возможного.

Мелочь, которая ломает фиксированную длину

Уже по ходу дела выяснилась деталь, которую я по неопытности мог и пропустить. Подпись ECDSA записывают в двух видах, и один из них имеет плавающую длину: числа r и s кодируются как целые, а у них бывает разное количество значащих байт. Я подписал один и тот же блок данных пять тысяч раз.

Формат записи

Что получилось

Итог

DER, он же Rfc3279DerSequence

69 Б — 0,2 %, 70 Б — 25,3 %, 71 Б — 49,7 %, 72 Б — 24,9 %

длина плавает

IEEE P1363, он же r‖s

64 Б — 100 % случаев

длина фиксирована

Для формата, где длина ключа известна заранее и разбор идёт по смещениям, годится только второй. К счастью, в .NET именно он стоит по умолчанию: SignData без указания формата возвращает P1363. Если бы я машинально взял DER, как принято в мире OpenSSL, ключ получился бы переменной длины, а разбор оброс проверками на ровном месте.

Похожая история с публичным ключом, который вшивается в приложение. В полном виде, с описанием алгоритма и кривой, он занимает 91 байт и превращается в 124 символа Base64 (выбор на более компактном варианте, т.к. читаемость уже не нужна, ключ вшит). Но кривая у меня одна и известна заранее, поэтому я храню только сами координаты точки: 64 байта, то есть 88 символов. Мелочь, а константа в исходнике выглядит человечнее. Кстати, именно эти 88 символов однажды вставили в окно активации вместо лицензионного ключа — с тех пор программа отдельно объясняет, что публичный ключ и ключ лицензии это разные вещи.

Чем пришлось “заплатить”

Было бы нечестно закончить на том, какой я молодец. У выбранного варианта есть недостаток, и он не косметический.

ECDSA зависит от качества случайного числа. При каждой подписи генерируется одноразовое значение, и если оно повторится для двух разных сообщений или окажется предсказуемым, приватный ключ вычисляется из этих подписей. На этом в своё время сгорели вполне серьёзные системы. У Ed25519 такой проблемы нет по построению: там значение выводится детерминированно из ключа и сообщения по RFC 8032. Мой аргумент в свою защиту скромный: подписываю я на одной офлайн-машине штатным генератором операционной системы, а не на устройствах пользователей, так что поверхность у этого риска маленькая. Но риск есть, и делать вид, что его нет, я не буду.

Побочный эффект той же недетерминированности проще: если перевыпустить одну и ту же лицензию дважды, строки ключа получатся разные. Мелочь, но реестр выданных ключей это должен учитывать, иначе однажды удивишься.

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

Что в итоге

Я остался на ECDSA P-256 с SHA-256: подпись 64 байта, ключ 202 символа, проверка 0,19 мс, ноль сторонних зависимостей, кроссплатформенно из коробки — сервер, который у меня подписывает служебные подтверждения, работает на Linux тем же кодом. Пять вещей, которые я из этого вынес.

1. «Модно» и «доступно» — разные списки. Ed25519 стоит везде, где вы посмотрите, кроме стандартной библиотеки той платформы, на которой вы пишете. Проверять надо не общественное мнение, а свою сборку.

2. Критерий выбора диктует форма продукта, а не бенчмарк. У меня всё решила длина строки в письме: 202 символа против 571. Скорость подписи, вокруг которой обычно ломают копья, не повлияла ни на что.

3. Зависимость живёт своим графиком. Четыре мегабайта чужого кода — это не только четыре мегабайта: это ещё и чужие решения о том, какие версии платформы поддерживать.

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

5. Проигрыш в бенчмарке — не повод менять решение. Мой алгоритм медленнее обоих конкурентов на проверке. На фоне 6,81 мс разовой инициализации это не значит ничего.

Спасибо, если дочитали.

Если у вас в проекте лежит криптография, выбранная «по умолчанию, все же так делают», — самое время открыть и проверить, что именно вы выбрали и почему.

ссылка на оригинал статьи https://habr.com/ru/articles/1063734/