Коротко. ML-DSA — схема подписи на основе решёток, стандартизованная в FIPS 204 (август 2024 года); прежнее название — CRYSTALS-Dilithium. У неё три набора параметров: ML-DSA-44, ML-DSA-65 и ML-DSA-87. Их подписи занимают 2420, 3309 и 4627 байт, а у Ed25519 или ECDSA P-256 — 64. По умолчанию подписание рандомизированное (hedged); детерминированный режим меняет только то, что выдаёт подписант. Необязательная строка контекста длиной до 255 байт и отдельный вариант с предварительным хешированием (HashML-DSA) меняют то, что должна делать проверяющая сторона, поэтому обеим сторонам нужно о них договориться. ML-DSA реализован в OpenSSL 3.5, Go 1.27, Java 24+, .NET 10, BouncyCastle и @noble/post-quantum. Больше всего расхождений между ними — в закрытых ключах. Храните 32-байтный seed.
Почему ML-DSA и почему сейчас
Подписи ECDSA, EdDSA и RSA может подделать любой, у кого есть достаточно большой квантовый компьютер. Но, в отличие от зашифрованного трафика, подпись нельзя собрать сейчас и взломать позже так, чтобы это навредило прошлому. Поддельная подпись имеет значение в тот момент, когда на неё кто-то полагается. Поэтому срочной миграции в первую очередь требовал обмен ключами, а у подписей времени больше. Но и этот запас времени не бесконечен:
- Долгоживущие корни доверия — ключи подписи прошивок, корневые сертификаты центров сертификации (certificate authority, CA), ключи подписи кода и документов — должны оставаться доверенными десять или двадцать лет после создания.
- NIST в проекте плана перехода IR 8547 предлагает объявить классические подписи со стойкостью 112 бит устаревшими после 2030 года и запретить все уязвимые для квантового компьютера подписи после 2035 года. Сроки и то, как с их учётом планировать работу, разобраны в статье Постквантовая миграция в 2026 году.
ML-DSA — универсальный ответ NIST. Родственные схемы закрывают отдельные ниши. FIPS 205 SLH-DSA — схема на основе хеш-функций с консервативными допущениями, маленькими ключами и очень большими или медленными подписями. Схемы с сохранением состояния LMS и XMSS на основе хеш-функций (SP 800-208) применяются для прошивок. FN-DSA на основе Falcon запланирован как FIPS 206. К октябрю 2026 года NIST ещё не опубликовал его проект на csrc.nist.gov.
Три набора параметров
| ML-DSA-44 | ML-DSA-65 | ML-DSA-87 | |
|---|---|---|---|
| Категория стойкости NIST | 2 | 3 | 5 |
| Открытый ключ | 1312 байт | 1952 байта | 2592 байта |
| Подпись | 2420 байт | 3309 байт | 4627 байт |
| Развёрнутый закрытый ключ | 2560 байт | 4032 байта | 4896 байт |
| Seed закрытого ключа | 32 байта | 32 байта | 32 байта |
| Открытый ключ в SPKI (DER) | 1334 байта | 1974 байта | 2614 байт |
| OID (RFC 9881) | 2.16.840.1.101.3.4.3.17 | …3.18 | …3.19 |
Размеры в необработанном виде взяты из таблицы 2 FIPS 204. Размеры SPKI — то, что OpenSSL 3.5.7 записал для только что сгенерированных ключей. Для сравнения: открытый ключ Ed25519 занимает 32 байта, а его подпись — 64. Открытый ключ RSA-2048 в SPKI занимает 294 байта, а его подпись — 256.
Какой набор выбрать:
- ML-DSA-65 — обычный выбор по умолчанию. По JEP 497 Java использует его, если набор параметров не указан, а большинство рекомендаций считает категорию 3 удобной золотой серединой.
- ML-DSA-87 — то, чего CNSA 2.0 от NSA требует для систем национальной безопасности США («параметры уровня V» для всех уровней секретности).
- ML-DSA-44 — самый компактный набор. Он подходит там, где важен каждый байт, а данные живут недолго. В документации Go даже сказано: «Большинству приложений следует использовать MLDSA44».
На практике байты важнее скорости. На виртуальной машине Linux на arm64, которую мы использовали для этой статьи, прогон openssl speed длительностью 2 секунды на одном ядре (OpenSSL 3.5.7) показал такие результаты:
| Алгоритм | Подписей/с | Проверок/с |
|---|---|---|
| ML-DSA-65 | 1 488 | 8 019 |
| RSA-2048 | 1 825 | 74 119 |
| ECDSA P-256 | 65 465 | 24 446 |
ML-DSA-65 подписывает примерно со скоростью RSA-2048, а проверяет подписи на порядок медленнее RSA; для большинства сервисов этого достаточно. Размер меняется куда сильнее. Цепочка сертификатов, JWT или подписанная запись журнала вырастают на килобайты. Конечный сертификат ML-DSA-65, подписанный CA с ключом ML-DSA-65, в нашем тесте занял 5514 байт в DER.
Рандомизация, детерминизм и контекст: что влияет на подпись
Три настройки из FIPS 204 определяют, какие байты выдаст Sign и что нужно сообщить Verify.
Рандомизированное или детерминированное подписание. При подписании ML-DSA внутри себя выводит секретное значение для каждой подписи из закрытого ключа, сообщения и 32-байтного значения rnd. По умолчанию (режим hedged) rnd — свежие случайные данные. В детерминированном варианте это 32 нулевых байта. Компромисс разобран в FIPS 204, § 3.4. Заранее вычисленная часть защищает от неисправного генератора случайных чисел, а свежая часть затрудняет атаки по побочным каналам и атаки с внесением сбоев. Там же добавлено, что детерминированный вариант «не следует использовать на платформах, где атаки по побочным каналам представляют угрозу». Проверка в обоих случаях одинакова, поэтому выбор касается только подписанта. OpenSSL наглядно показывает разницу:
$ openssl pkeyutl -sign -rawin -inkey k65.pem -in msg.txt | sha256sum
f5c30f1347b231f64f7adf48a4dfeb77d1c259cfc568420ad1f2238335e28616 -
$ openssl pkeyutl -sign -rawin -inkey k65.pem -in msg.txt | sha256sum
c0eada83bdaa8e278defdd974cbd849fac583f7519a075ee53ec2a3d93adc6ba -
$ openssl pkeyutl -sign -rawin -inkey k65.pem -in msg.txt -pkeyopt deterministic:1 | sha256sum
c179822bec24d34a38c16257b41624cfb3a9ed40b7d7c79a5b5f2bdf0b87d4bf -
$ openssl pkeyutl -sign -rawin -inkey k65.pem -in msg.txt -pkeyopt deterministic:1 | sha256sum
c179822bec24d34a38c16257b41624cfb3a9ed40b7d7c79a5b5f2bdf0b87d4bf -
Не пишите тесты, которые сравнивают новую подпись с сохранённой, если только вы не подписываете детерминированно. Рандомизированные подписи при каждом вызове разные, и так задумано.
Строка контекста. И Sign, и Verify принимают строку контекста ctx длиной не более 255 байт, по умолчанию пустую. ML-DSA подписывает 0x00 ‖ len(ctx) ‖ ctx ‖ M, а не только M, поэтому подпись, созданная с одним контекстом, не проходит проверку с другим. Контекст — дешёвый инструмент разделения доменов. Если два протокола используют общий ключ, но у каждого свой контекст, их нельзя обманом заставить принять подписи друг друга. Но если контекст задаёт только одна сторона, проверка ломается неочевидным образом:
$ openssl pkeyutl -sign -rawin -inkey k65.pem -in msg.txt -pkeyopt context-string:invoice-v1 -out sigctx.bin
$ openssl pkeyutl -verify -rawin -pubin -inkey p65.pem -in msg.txt -sigfile sigctx.bin -pkeyopt context-string:invoice-v1
Signature Verified Successfully
$ openssl pkeyutl -verify -rawin -pubin -inkey p65.pem -in msg.txt -sigfile sigctx.bin
...:error:030000EA:digital envelope routines:EVP_DigestVerify:provider signature failure:../crypto/evp/m_sigver.c:781:ML-DSA-65 digest_verify:
Signature Verification Failure
Контекст фиксируют протоколы, поэтому выбирать его самим приходится редко. И ML-DSA в X.509 (RFC 9881), и ML-DSA в JOSE и COSE (RFC 9964, май 2026 года) используют пустой контекст. В RFC 9964 сказано: «Параметр ctx ДОЛЖЕН быть пустой строкой».
HashML-DSA. В FIPS 204, § 5.4 определён ещё и вариант с предварительным хешированием. Сообщение сначала хешируется SHA-512, SHA-256, SHAKE128 или другой одобренной функцией, а подписывается дайджест вместе с OID хеш-функции и другим байтом разделения доменов (1 вместо 0). Это отдельная схема подписи с собственными OID. Подпись HashML-DSA не будет действительной подписью ML-DSA для того же сообщения. В FIPS 204 сказано: «предпочтительна „чистая“ версия ML-DSA», а по RFC 9881 HashML-DSA в сертификатах не допускается. В RFC 9964 он также не определён ни для JOSE, ни для COSE. Используйте его, только если этого требует протокол.
Для очень больших сообщений есть третий путь — внешнее μ (external μ). Подписант вычисляет 64-байтный представитель сообщения μ вне модуля подписи и передаёт в модуль только его. Этот способ описан в RFC 9881. Go 1.27 (crypto.MLDSAMu) и .NET 10 (SignMu) его поддерживают. Получаются обычные подписи чистого ML-DSA, поэтому проверяющим сторонам не нужно ничего особенного.
Форматы закрытых ключей: храните seed
Пара ключей ML-DSA генерируется из 32-байтного seed ξ. Из этого seed вычисляется «развёрнутый» закрытый ключ размером 2560–4896 байт, а также открытый ключ. В RFC 9881 определены три кодировки PKCS#8:
seed: только 32 байта,[0] IMPLICIT OCTET STRING. Это рекомендуемая форма. В PKCS#8 она занимает 54 байта.expandedKey: только развёрнутый ключ.both: последовательность из seed и развёрнутого ключа.
RFC рекомендует реализациям «сохранять и экспортировать seed». Развёртывание необратимо, поэтому ключ, сохранённый без seed, уже никогда не получится преобразовать обратно в компактную форму. Библиотеки выбрали разные форматы по умолчанию, и из-за этого ломается импорт. OpenSSL 3.5 по умолчанию записывает both:
0:d=0 hl=4 l=4094 cons: SEQUENCE
4:d=1 hl=2 l= 1 prim: INTEGER :00
7:d=1 hl=2 l= 11 cons: SEQUENCE
9:d=2 hl=2 l= 9 prim: OBJECT :ML-DSA-65
20:d=1 hl=4 l=4074 prim: OCTET STRING [HEX DUMP]:30820FE60420A58A79B197C3...
Go 1.27 отказывается принимать такую форму и подсказывает, что делать. Он принимает только seed:
both.pem (4098 bytes): x509: ML-DSA private keys with both seed and expanded key are not supported, use e.g. "openssl pkey -provparam ml-dsa.output_formats=seed-only" to convert to a seed-only key
seed-only.pem (54 bytes): *mldsa.PrivateKey
expanded-only.pem (4060 bytes): x509: semi-expanded ML-DSA private keys without seed are not supported
В наших тестах Java 25 (JEP 497), .NET 10 и Go экспортировали ключи PKCS#8 только с seed, размером 54 байта. Выбирайте форму seed там, где ключ создаётся:
openssl genpkey -algorithm ML-DSA-65 -provparam ml-dsa.output_formats=seed-only -out key.pem
openssl pkey -in both.pem -provparam ml-dsa.output_formats=seed-only -out key.pem
То же самое относится к HSM и KMS. Прежде чем генерировать долгоживущий ключ, убедитесь, что seed можно экспортировать или сохранить в резервной копии. Ключ, сохранённый только в развёрнутой форме, привязывает вас к реализациям, которые принимают развёрнутые ключи. Показательный пример — @noble/post-quantum: его secretKey — развёрнутая кодировка (4032 байта для ML-DSA-65), а keygen(seed) принимает 32-байтный seed. Храните seed сами.
Чтобы проверить, что на самом деле содержится в ключе или сертификате, воспользуйтесь инспектором постквантовых сертификатов: он декодирует ключи и сертификаты ML-DSA и SLH-DSA, а в просмотре структуры ASN.1 видно, какая из трёх форм закрытого ключа у вас.
Код, который работает уже сегодня
Каждый фрагмент кода ниже запущен 20 октября 2026 года в контейнере, который указан в абзаце перед этим фрагментом. Все они подписывают "hello" с помощью ML-DSA-65 и проверяют результат. Если библиотека это поддерживает, задаётся строка контекста. Чтобы попробовать схему, ничего не устанавливая, откройте лабораторию постквантовых подписей: она подписывает и проверяет ML-DSA, SLH-DSA и Falcon прямо в браузере.
OpenSSL 3.5 (node:24.22.0-trixie, OpenSSL 3.5.7). Для ML-DSA нужен -rawin, иначе pkeyutl ожидает заранее вычисленный дайджест:
openssl genpkey -algorithm ML-DSA-65 -provparam ml-dsa.output_formats=seed-only -out key.pem
openssl pkey -in key.pem -pubout -out pub.pem
openssl pkeyutl -sign -rawin -inkey key.pem -in msg.txt -out sig.bin
openssl pkeyutl -verify -rawin -pubin -inkey pub.pem -in msg.txt -sigfile sig.bin
# Signature Verified Successfully
Go 1.27 (golang:1.27.3-trixie). Новый пакет crypto/mldsa. crypto/x509 разбирает и сериализует ключи, а crypto/tls принимает сертификаты ML-DSA в TLS 1.3. В документации пакета предупреждается, что с модулем FIPS 140-3 Go Cryptographic Module v1.0.0 он недоступен.
sk, err := mldsa.GenerateKey(mldsa.MLDSA65())
if err != nil {
log.Fatal(err)
}
opts := &mldsa.Options{Context: "invoice-v1"}
sig, err := sk.Sign(nil, []byte("hello"), opts) // 3309 bytes; the io.Reader is ignored
if err != nil {
log.Fatal(err)
}
fmt.Println(mldsa.Verify(sk.PublicKey(), []byte("hello"), sig, opts)) // <nil>
fmt.Println(mldsa.Verify(sk.PublicKey(), []byte("hello"), sig, nil)) // mldsa: invalid signature
sk.SignDeterministic даёт детерминированный вариант. sk.Bytes() возвращает 32-байтный seed.
Java, JDK 24 и новее (eclipse-temurin:25.0.4.1_1-jdk-noble). С JEP 497 ML-DSA появился в стандартных провайдерах. Строки контекста и HashML-DSA явно исключены из целей этого JEP, поэтому JDK подписывает только с пустым контекстом.
KeyPair kp = KeyPairGenerator.getInstance("ML-DSA-65").generateKeyPair();
Signature signer = Signature.getInstance("ML-DSA");
signer.initSign(kp.getPrivate());
signer.update("hello".getBytes(StandardCharsets.UTF_8));
byte[] sig = signer.sign(); // 3309 bytes
// kp.getPublic().getEncoded().length == 1974, kp.getPrivate().getEncoded().length == 54
BouncyCastle 1.86 (тот же образ JDK). Контекст поддерживается через ContextParameterSpec, а HashML-DSA — под отдельным именем алгоритма. Наш тест подтверждает, что подпись HashML-DSA не проходит проверку как подпись чистого ML-DSA:
Signature signer = Signature.getInstance("ML-DSA", "BC");
signer.initSign(kp.getPrivate());
signer.setParameter(new ContextParameterSpec("invoice-v1".getBytes(StandardCharsets.UTF_8)));
signer.update(msg);
byte[] sig = signer.sign(); // verifies only with the same context
Signature pre = Signature.getInstance("HASH-ML-DSA", "BC"); // SHA-512 pre-hash
pre.initSign(kp.getPrivate());
pre.update(msg);
byte[] preSig = pre.sign(); // "ML-DSA".verify(preSig) == false
.NET 10 (mcr.microsoft.com/dotnet/sdk:10.0.402-alpine3.24). System.Security.Cryptography.MLDsa использует криптографическую библиотеку платформы: в Linux это OpenSSL 3.5 или новее, в Windows — CNG с поддержкой PQC. Одна и та же программа сообщила MLDsa.IsSupported: False в образе SDK на Ubuntu 24.04 (10.0.402-noble, OpenSSL 3.0.13) и True на Alpine 3.24 (OpenSSL 3.5.9). Перед выпуском проверьте IsSupported и свой базовый образ. В SDK 10.0.402 генерация ключей, подписание и проверка компилируются без проблем, но методы экспорта ключей по-прежнему ломают сборку ошибкой error SYSLIB5006: ... is for evaluation purposes only, если не подавить эту диагностику.
using MLDsa key = MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa65);
byte[] data = Encoding.UTF8.GetBytes("hello");
byte[] context = Encoding.UTF8.GetBytes("invoice-v1");
byte[] signature = key.SignData(data, context); // 3309 bytes
Console.WriteLine(key.VerifyData(data, signature, context)); // True
Console.WriteLine(key.VerifyData(data, signature)); // False
#pragma warning disable SYSLIB5006 // the key export methods are still [Experimental] in .NET 10
byte[] pkcs8 = key.ExportPkcs8PrivateKey(); // 54 bytes: the seed form
JavaScript и TypeScript. Встроенная поддержка уже появляется. Node.js 24.22 создаёт ключи вызовом crypto.generateKeyPairSync("ml-dsa-65") и подписывает вызовом crypto.sign(null, data, privateKey) через встроенный OpenSSL 3.5, а его WebCrypto принимает { name: "ML-DSA-65" } с предупреждением ExperimentalWarning. Chromium 156 (сборка из Playwright 1.64.1) сгенерировал ключ и 3309-байтную подпись через crypto.subtle на HTTPS-странице. Прежде чем на это полагаться, проверьте движки других браузеров. Для кода, который должен работать везде, есть @noble/post-quantum 0.7.0 — реализация на чистом JS, которая работает в Node.js и в любом браузере (запущено в node:24.22.0-trixie):
import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
import { randomBytes } from "node:crypto";
const { secretKey, publicKey } = ml_dsa65.keygen(randomBytes(32)); // keep the 32-byte seed, not secretKey
const msg = new TextEncoder().encode("hello");
const context = new TextEncoder().encode("invoice-v1");
const sig = ml_dsa65.sign(msg, secretKey, { context });
ml_dsa65.verify(sig, msg, publicKey, { context }); // true
ml_dsa65.verify(sig, msg, publicKey); // false
ml_dsa65.sign(msg, secretKey, { extraEntropy: false }); // deterministic variant
Сертификаты ML-DSA и TLS
OpenSSL 3.5 уже сегодня умеет выпускать сертификаты ML-DSA и использовать их в TLS 1.3. Этот тест создаёт корневой и конечный сертификаты ML-DSA-65 и подключается через s_client. Клиент, который предлагает только классические алгоритмы подписи, получает отказ:
leaf certificate: 5514 bytes DER
Protocol version: TLSv1.3
Signature type: mldsa65
Verification: OK
Negotiated TLS1.3 group: X25519MLKEM768
--- a classic-only client
error:0A000410:SSL routines:ssl3_read_bytes:ssl/tls alert handshake failure
Кодовые значения TLS (mldsa44 0x0904, mldsa65 0x0905, mldsa87 0x0906) взяты из draft-ietf-tls-mldsa. В октябре 2026 года этот черновик находился в очереди RFC Editor. Go 1.27 поддерживает те же три схемы.
Для публичного веба это пока не годится. Браузеры не принимают сертификаты ML-DSA от публичных CA. Firefox поддерживает их только при включённом параметре security.tls.enable_mldsa, который по умолчанию равен false. Chrome заявил в феврале 2026 года, что в ближайшее время не планирует добавлять в своё хранилище корневых сертификатов сертификаты X.509 с постквантовыми ключами. Вместо этого он работает над Merkle Tree Certificates, которые позволяют не отправлять в каждом рукопожатии несколько подписей размером в килобайты. Уже сегодня сертификаты ML-DSA реалистичны в частной PKI: в сервисных сетках (service mesh), для удостоверений устройств, во внутреннем mTLS, для подписи кода и документов там, где вы контролируете обе стороны.
Типичные ошибки
- Ожидание стабильной подписи. Во всех перечисленных выше библиотеках по умолчанию используется рандомизированное подписание (мы проверили OpenSSL, Java и noble, подписав дважды), поэтому одни и те же входные данные каждый раз дают разные байты. Сравнивайте результаты проверки, а не байты подписей.
- Путаница между чистым ML-DSA и HashML-DSA. Это разные алгоритмы с разными OID. Проверяющая сторона, которая ожидает один из них, отвергает другой, а FIPS 204 рекомендует использовать каждую пару ключей только с одним из них.
- Контекст, заданный только на одной стороне. Сбой выглядит точно так же, как неверная подпись. Записывайте в журнал контекст, который использует ваш протокол, рядом с именем алгоритма.
- Удаление seed. Экспорт только развёрнутого ключа работает, пока не понадобится загрузить ключ в Go или другую реализацию, которая принимает только seed. Преобразуйте ключ с помощью
-provparam ml-dsa.output_formats=seed-only, пока это ещё возможно. - Расчёт на то, что среда выполнения это поддерживает. .NET и другие обёртки над системной библиотекой наследуют её версию. Приложение, которое работает на ноутбуке разработчика с OpenSSL 3.5, может не заработать в контейнере, собранном на более старом базовом образе.
- Забытые ограничения размера. Заголовки JWT, cookie, DNS-записи, расширения X.509, столбцы базы данных, QR-коды, буферы HSM: всё, что рассчитано на 64-байтную подпись ECDSA, должно вмещать 3309 байт и больше.
Источники
- FIPS 204, Module-Lattice-Based Digital Signature Standard, стандарт цифровой подписи на основе модульных решёток (август 2024 года): наборы параметров (таблица 1), размеры (таблица 2), рандомизированное и детерминированное подписание (§ 3.4), контекст и HashML-DSA (§§ 5.2–5.4).
- RFC 9881: идентификаторы алгоритмов ML-DSA для X.509, форматы закрытых ключей, внешнее μ.
- RFC 9964: ML-DSA для JOSE и COSE (
ML-DSA-44/65/87, тип ключаAKP). - draft-ietf-tls-mldsa: ML-DSA в TLS 1.3.
- NIST IR 8547 (первый публичный проект): график перехода.
- Документация библиотек: OpenSSL 3.5 NEWS, примечания к выпуску Go 1.27, JEP 497, Что нового в библиотеках .NET 10.
Вывод команд получен 20 октября 2026 года в node:24.22.0-trixie (OpenSSL 3.5.7, Node.js 24.22.0), golang:1.27.3-trixie, eclipse-temurin:25.0.4.1_1-jdk-noble (с bcprov-jdk18on 1.86), mcr.microsoft.com/dotnet/sdk:10.0.402-alpine3.24, mcr.microsoft.com/dotnet/sdk:10.0.402-noble и mcr.microsoft.com/playwright:v1.64.1-noble (Chromium 156).