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