Введение
Если вы Java-разработчик, которому нужно поднять TLS 1.3 на ГОСТ или подписать документ CAdES, создать свой УЦ, и вы не хотите читать RFC — эта статья для вас. Все примеры в этой статье одинаково работают что на JVM 21, что на Android 12+ — тот же самый API, без единого отличия в коде.
crypto-gost — криптографическая библиотека алгоритмов ГОСТ на Java 21+ и Android 12+, а также сопутствующая обвязка вокруг них, позволяющая в полной мере внедрить ГОСТ в инфраструктуру Java. Библиотека предназначена для любых коммерческих проектов, личных проектов и сред тестирования.
Данная библиотека не является сертифицированной и не предназначена для работы там, где требуются сертифицированные средства криптографии.
Библиотека состоит из следующих основных модулей:
-
crypto-gost-core — криптографическое ядро алгоритмов ГОСТ: Стрибог, Кузнечик, ГОСТ Р 34.10-2012, JCA-провайдер.
-
crypto-gost-pkix — ГОСТ-инфраструктура PKIX: OID-реестр, CMS SignedData/EnvelopedData/SignedAndEnvelopedData, извлечение и формирование сертификатов из PKCS#7/.p7b, X.509-сертификаты, OCSP, CRL, CSR (PKCS#10), PKCS#12, CAdES-BES/T/C/X-Long Type 1, TSP, Countersignature.
-
crypto-gost-tls13 — Реализация протокола TLS 1.3 (RFC 8446) с поддержкой российских криптографических алгоритмов согласно RFC 9367.
-
crypto-gost-jsse — реализация JSSE-провайдера для Java.
Проблематика
Я считаю, что безопасность относится к фундаментальным свойствам любой ИТ-системы и является часть инфраструктурного уровня. Она должна быть бесплатной, быть простой для подключения, удобной для разработчиков, с минимальными рисками неверного использования. Для государственных структур или больших компаний есть обязанность и возможность купить сертифицированные отечественные средства криптографии в нужном количестве, а также уметь их встраивать и иметь на это соответствующие лицензии.
Но что делать простым Java-разработчикам, которым нужно написать обычный сайт, электронный магазин, чат, сделать несложный защищенный обмен документами? Во всех этих задачах нужна криптография,TLS, без этого никуда. Из коробки в большом количестве есть только зарубежные технологии. В текущих условиях стандарты ГОСТ в области криптографии стали актуальнее по многочисленным причинам: от запрета на выдачу сертификатов для доменной зоны .ru, до невозможности доверять зарубежной криптографии из-за отсутствия контроля над ней.
Какие пути есть у Java-разработчика по использованию ГОСТ?
-
Bouncycastle со всеми рисками использования зарубежных технологий и развитию в ней стандартов ГОСТ по остаточному принципу. В качестве примера развития ГОСТ по остаточному принципу приведу CVE-2025-14813 для режима шифрования CTR. Атакующий может восстановить открытый текст без знания ключа. Уязвимы все версии BC-JAVA от 1.59 до 1.83; Реализация отечественных стандартов и RFC идет с явным запозданием и непонятной степенью чистоты реализации.
-
OpenSSL+gost-engine+JNI: ад сборки и платформенной зависимости, несовместимости разных версий openssl в области ГОСТ;
-
Покупка за личные средства платных крипто-провайдеров JCP, установка платформо-зависимых компонентов в каждую ОС и далее поверх них установки Java-обертки.
-
Использовать малоизвестные библиотеки, предоставляющие очень ограниченный набор базовых крипто-операций. На этом далеко не уедешь, кроме примитивных задач.
Есть еще задачи вне зависимости от языка программирования и выбранной библиотеки: генерация и отзыв сертификатов, генерация ключей шифрования, передача ключевых данных, шифрование и подпись документов по стандартам, защищенный обмен по сети, которые точно нужны разработчику для решения практических задач. Это всё должно быть из коробки.
Все вышеописанные пути использования ГОСТ сильно контрастируют с возможностью из коробки использовать зарубежные средства криптографии и их взаимной интеграцией.
crypto-gost является попыткой получения того же уровня комфорта и свободы использования ГОСТ-криптографии «из коробки» для Java-разработчиков. Библиотека написана на чистой Java, позволяет построить любую ГОСТ-инфраструктуру as-code не выходя из IDE. Библиотека имеет открытую лицензию, не требует никаких зависимостей в ОС, кроме собственно JVM. Она поддерживает все актуальные стандарты, необходимые для решения практических задач.
Про лицензию
Если если кратко, то лицензия позволяет использовать библиотеку в любой сфере без ограничений. Библиотека создана только мной, частным лицом и на мои сбережения и не принадлежит никакой компании. Я написал её в период, когда искал работу.
Основные принципы и философия crypto-gost
При проектировании, реализации и аудите кода я пользовался следующими императивами:
-
Только ГОСТ-алгоритмы и стандарты , никаких зарубежных алгоритмов;
-
Безопасность — главный приоритет. Constant-time операции, строгая гигиена затирания ключевого материала, защита от downgrade/timing/truncation атак. Никаких старых стандартов. Поэтому только TLS 1.3, т.к. в TLS 1.2 разработчику есть возможность ошибиться при настройке, понизив тем самым безопасность.
-
fail-closed-принцип на security-критичных неоднозначностях. Если есть нарушение протокола → fatal alert + немедленное закрытие. Никогда не «продолжать с дефолтом».
-
No-debug — никаких секретов в выводе. Ключевой материал (private keys, traffic keys, master secrets, PSK, nonce, session IDs) не должен появляться: в toString() / toDebugString() / логгерах; в сообщениях исключений.
-
Stateless криптооперации, где возможно.
-
Там, где constant-time недостижим на JVM или требует неоправданных затрат, решение принимается осознанно и документируется в ADR с явной фиксацией модели угроз.
-
Библиотека должна иметь защиту от удаленных атак, но не от co-located, когда злоумышленник может исполнять свой код на той же машине.
-
-
Вторая по значимости характеристика — performance, но не за счет безопасности. Регулярный аудит кода и тесты на давление на GC или лишние аллокации на горячем пути.
-
Готовность к JDK 21 virtual threads.
-
Разработчику должно быть удобно. Все должно быть единообразно и симметрично (например симметричный API).
-
Всё, что нужно разработчику должно быть доступно из API. Никаких переключений типа: в openssl мы сертификаты генерим, парсим сертификат сторонней библиотекой, а в нашем коде мы только шифруем. Генерация сертификатов, выпуск CRL, создание CMS-контейнера, TLS-соединение — всё из одного API, без переключения между инструментами.
-
Совместимость (интероперабельность) с такими инструментами как: OpenSSL, Bouncycastle, отечественные коммерческие крипто-провайдеры. Реализация должна быть совместима, т.к. интеграция с внешними средствами важный параметр.
-
Библиотека должна иметь исчерпывающую документацию для разработчика.
Инфраструктура ГОСТ-сертификатов as code
В этом разделе мы посмотрим какие средства предоставляет crypto-gost разработчику. Единственный пререквизит: подключить maven-зависимость библиотеки к проекту.
Сравните это с тем, как сейчас у вас делается в проектах? Сколько пререквизитов и инструкций по настройке окружения вы должны выполнить, прежде чем получить аналогичный результат?
Генерация ключей и сертификатов
import org.rssys.gost.api.KeyGenerator;import org.rssys.gost.api.KeyPair;import org.rssys.gost.pkix.cert.GostCertificate;import org.rssys.gost.pkix.cert.GostCertificateBuilder;import org.rssys.gost.pkix.cert.GostDnParser;import org.rssys.gost.signature.ECParameters;ECParameters params = ECParameters.tc26a256();// 1. Корневой CA — самоподписанный, pathLen=null (без ограничения глубины)KeyPair rootKp = KeyGenerator.generateKeyPair(params);byte[] rootDn = GostDnParser.encodeDn("CN=My Root CA,O=MyOrg,C=RU");GostCertificate rootCert = GostCertificateBuilder.create(params, rootDn) .publicKey(rootKp.getPublic()) .randomSerial() .notBeforeNow() .validForYears(15) .keyUsage(GostCertificateBuilder.KeyUsage.KEY_CERT_SIGN) .basicConstraints(true, null) .sign(rootKp.getPrivate());// 2. Промежуточный CA — подписан корнем, pathLen=0 (может подписывать// только конечные сертификаты, не других CA)KeyPair intermediateKp = KeyGenerator.generateKeyPair(params);byte[] intermediateDn = GostDnParser.encodeDn("CN=My Intermediate CA,O=MyOrg,C=RU");GostCertificate intermediateCert = GostCertificateBuilder.create(params, intermediateDn) .publicKey(intermediateKp.getPublic()) .randomSerial() .notBeforeNow() .validForYears(5) .basicConstraints(true, 0) .issuerDn(rootDn) .sign(rootKp.getPrivate());// 3. Серверный сертификат — подписан промежуточным CAKeyPair serverKp = KeyGenerator.generateKeyPair(params);GostCertificate serverCert = GostCertificateBuilder.create(params, GostDnParser.encodeDn("CN=example.com,O=MyOrg,C=RU")) .publicKey(serverKp.getPublic()) .randomSerial() .notBeforeNow() .validForYears(1) .sanDns("example.com") .keyUsage(GostCertificateBuilder.KeyUsage.DIGITAL_SIGNATURE) .extendedKeyUsage(GostOids.EXT_SERVER_AUTH) .issuerDn(intermediateDn) .sign(intermediateKp.getPrivate());
-
basicConstraints(true, null) у корня vs basicConstraints(true, 0) у промежуточного — это реальное ограничение длины цепочки: pathLen=0 у intermediate буквально запрещает ему подписывать других CA, только конечные сертификаты. Один из самых частых источников ошибок при ручной сборке PKI через openssl — забыть это ограничение.
-
issuerDn(…) связывает уровни явно — каждый следующий сертификат ссылается на DN родителя, а подписывается его закрытым ключом (.sign(rootKp.getPrivate())/.sign(intermediateKp.getPrivate())) — цепочка выстраивается через два независимых, но согласованных вызова.
-
.sanDns(«example.com«) на серверном сертификате — то самое место, без которого verifyHostname() на клиентской стороне не заработает.
Экспорт и импорт ключей и сертификатов (PEM/DER/PKCS12)
Чаще всего разработчик не генерирует для своего проекта ключевые данные, а получает ключи и сертификаты извне. Давайте рассмотрим, как сохранить и загрузить ключи, сертификаты и проверить их актуальность?
import org.rssys.gost.pkix.cert.ChainValidator;import org.rssys.gost.pkix.cert.GostCertificate;import org.rssys.gost.pkix.cert.GostPkcs12Builder;import org.rssys.gost.pkix.cert.GostPkcs12Loader;import org.rssys.gost.pkix.cert.PkixException;import org.rssys.gost.signature.PrivateKeyParameters;import java.nio.charset.StandardCharsets;import java.util.Arrays;import java.util.List;// ============================================================// Сериализация ключей и сертификатов// ============================================================// Код ниже базируется на примере выше генерации ключей и сертификатов// PEM ↔ DER: экспорт и импортString serverPem = serverCert.toPem();byte[] serverDer = serverCert.getEncoded();// Библиотека сама разберется это PEM или DER, избавляя разработчика от "игр с // конвертацией" сертификатов в разные форматыGostCertificate fromPem = GostCertificate.fromPemOrDer(serverPem.getBytes(StandardCharsets.US_ASCII));GostCertificate fromDer = GostCertificate.fromDer(serverDer);// Байт-в-байт равенство после round-tripSystem.out.println("PEM round-trip идентичен: " + Arrays.equals(serverCert.getEncoded(), fromPem.getEncoded()));System.out.println("DER round-trip идентичен: " + Arrays.equals(serverCert.getEncoded(), fromDer.getEncoded()));//PKCS#12: сохранение ключа + полной цепочки для хранения/переноса byte[] pfx = GostPkcs12Builder.create() .key(serverKp.getPrivate()) .certificate(serverCert) .caCertificate(intermediateCert) // caCertificate() — аккумулятор, .caCertificate(rootCert) // можно вызывать несколько раз подряд .password("changeit".toCharArray()) .friendlyName("server-cert") .build();GostPkcs12Loader.Result loaded = GostPkcs12Loader.load(pfx, "changeit".toCharArray(), /* allowJdkFallback */ false);PrivateKeyParameters loadedKey = loaded.getPrivateKey();List<GostCertificate> loadedChain = loaded.getCertificateChain();System.out.println("Из PKCS12: ключ + цепочка из " + loadedChain.size() + " сертификатов");// ============================================================// ChainValidator — проверка цепочек сертификатов // ============================================================// Позитивный кейс: полная цепочка, корень в списке доверенныхList<GostCertificate> fullChain = List.of(serverCert, intermediateCert, rootCert);ChainValidator.validateChain(fullChain, List.of(rootCert.getPublicKey()));System.out.println("Цепочка валидна — исключения не было");// Негативный кейс: пропущен intermediate// Частая реальная ошибка конфигурации TLS-сервера: отдали leaf и root,// забыли intermediate. Интуитивно ждёшь DN_MISMATCH (issuer у serverCert —// DN промежуточного CA, а не root), но в реализации проверка подписи// стоит раньше проверки DN — до сравнения DN исполнение не доходит.try { ChainValidator.validateChain( List.of(serverCert, rootCert), List.of(rootCert.getPublicKey())); System.out.println("Не должно было пройти!");} catch (PkixException e) { System.out.println("Отклонено, как и ожидалось: " + e.reason()); // SIGNATURE_INVALID}// Негативный кейс: корень не в списке доверенных// Цепочка технически целая и подписи все верны, но доверяем только// intermediate, а не настоящему корню — валидатор не «догадывается»// прошагать выше, он требует явного доверия именно к root.try { ChainValidator.validateChain(fullChain, List.of(intermediateCert.getPublicKey())); System.out.println("Не должно было пройти!");} catch (PkixException e) { System.out.println("Отклонено, как и ожидалось: " + e.reason()); // ROOT_NOT_SIGNED}
Проверка отзыва сертификатов (CRL/OCSP)
В примере выше мы загрузили ключ для сервера и проверили цепочку сертификатов. А как узнать, что сертификат не отозван?
import org.rssys.gost.pkix.cert.CrlVerifier;import org.rssys.gost.pkix.cert.GostCrlBuilder;import org.rssys.gost.pkix.cert.GostOcspResponseBuilder;import org.rssys.gost.pkix.cert.OcspVerifier;import org.rssys.gost.pkix.cert.PkixException;import org.rssys.gost.pkix.cert.ReasonCode;// Валидная цепочка ещё не значит «доверяю»// ============================================================// CRL и OCSP — методы проверки статуса сертификатов.// ============================================================// ChainValidator ответил "да, цепочка подлинная". Но CA мог отозвать// serverCert уже после выпуска (утечка ключа, замена и т.д.) —// цепочка при этом остаётся математически валидной. Проверка отзыва —// отдельный, самостоятельный шаг доверия.// CRL: "позитивный" кейс — сертификат не в списке отзываbyte[] emptyCrl = GostCrlBuilder.create(intermediateDn) .thisUpdateNow() .nextUpdateDays(7) .withCrlNumber(1) .sign(intermediateKp.getPrivate());// Клиент проверяетCrlVerifier.verify( emptyCrl, serverCert.getSerialNumber(), intermediateKp.getPublic(), serverCert.getIssuerDnBytes());System.out.println("CRL: сертификат не отозван — исключения не было");// CRL: тот же CA публикует новый список, теперь serverCert в нём отозванbyte[] crlWithRevocation = GostCrlBuilder.create(intermediateDn) .thisUpdateNow() .nextUpdateDays(7) .withCrlNumber(2) .addRevoked(serverCert.getSerialNumber(), "20250601000000Z") .sign(intermediateKp.getPrivate());// Клиент проверяетtry { CrlVerifier.verify( crlWithRevocation, serverCert.getSerialNumber(), intermediateKp.getPublic(), serverCert.getIssuerDnBytes()); System.out.println("Не должно было пройти!");} catch (PkixException e) { System.out.println("CRL: отклонено — " + e.reason()); // REVOKED}// OCSP: сертификат не отозван, синхронно и без скачивания всего CRL-спискаbyte[] ocspGood = GostOcspResponseBuilder.create(serverCert.getSerialNumber()) .issuerDn(intermediateDn) .good() .sign(intermediateKp.getPrivate());// Клиент проверяетOcspVerifier.verify(ocspGood, serverCert.getSerialNumber(), intermediateCert);System.out.println("OCSP: статус good — исключения не было");// OCSP: тот же CA отвечает "отозван", причина — компрометация ключаbyte[] ocspRevoked = GostOcspResponseBuilder.create(serverCert.getSerialNumber()) .issuerDn(intermediateDn) .revoked("20250601000000Z", ReasonCode.KEY_COMPROMISE) .sign(intermediateKp.getPrivate());// Клиент проверяетtry { OcspVerifier.verify(ocspRevoked, serverCert.getSerialNumber(), intermediateCert); System.out.println("Не должно было пройти!");} catch (PkixException e) { System.out.println("OCSP: отклонено — " + e.reason()); // REVOKED}
-
CRL и OCSP подписаны intermediateKp— потому что именно intermediate CA выпустил сертификат. OcspVerifier.verify(…, issuerCert) принимает сам сертификат intermediateCert, а не голый ключ — верификатор сам вычисляет issuerNameHash/issuerKeyHash из него.
-
CrlVerifier.verify и OcspVerifier.verify — единая точка входа и для «структура не валидна» и «сертификат отозван». Это одно и то же checked-исключение PkixException с разным Reason (REVOKED против, скажем, SIGNATURE_INVALID для подделанного CRL/OCSP-ответа). «Не доверяю» в библиотеке всегда выражается одним и тем же способом, а не разными исключениями/кодами возврата в разных проверках.
Для проверки статуса сертификатов онлайн можно воспользоваться безопасными фетчерами, встроенными в crypto-gost:
import org.rssys.gost.jsse.crl.JdkHttpCrlFetcher;import org.rssys.gost.jsse.ocsp.JdkHttpOcspFetcher;// URL берём из проверяемого сертификатаString[] cdpUris = serverCert.getCdpUris(); // CRLDistributionPointsString[] ocspUris = serverCert.getOcspUris(); // AIA: OCSP-responder'ы (без caIssuers)// Скачиваем онлайн CRL и проверяем if (cdpUris != null && cdpUris.length > 0) { byte[] crlDer = new JdkHttpCrlFetcher().fetch(cdpUris[0]); CrlVerifier.verify( crlDer, serverCert.getSerialNumber(), intermediateKp.getPublic(), serverCert.getIssuerDnBytes()); System.out.println("CRL скачан и проверен: " + cdpUris[0]);}// Запрашиваем OCSP-ответ онлайнif (ocspUris != null && ocspUris.length > 0) { byte[] ocspDer = new JdkHttpOcspFetcher() .fetch(serverCert.getEncoded(), intermediateCert.getEncoded(), ocspUris[0]); OcspVerifier.verify(ocspDer, serverCert.getSerialNumber(), intermediateCert); System.out.println("OCSP-ответ получен и проверен: " + ocspUris[0]);}
URI извлечены из пока недоверенного сертификата. Атакующий, контролирующий выпуск сертификата, может вписать любой URI: внутренние адреса (192.168.x.x, 127.0.0.1), file://, ldap:// и т.д. — прямой SSRF-вектор.
Если фетчить URL из сертификата вручную (через HttpClient напрямую, а не через JdkHttpCrlFetcher/JdkHttpOcspFetcher), то нужно самому: проверить схему (только http/https), заблокировать приватные диапазоны после DNS-резолва, ограничить таймаут и размер ответа. Штатные фетчеры библиотеки (JdkHttpCrlFetcher, JdkHttpOcspFetcher) делают эти проверки автоматически через SsrfGuard.isPrivateAddress().
Остаточный риск: адрес проверяется до запроса, но реальное соединение делает HttpClient, который заново резолвит DNS — то есть между проверкой и соединением есть окно для DNS-rebinding.
Генерация CSR-запроса в УЦ и обработка ответа
Для разработки сайта часто приходится иметь дело со сторонним УЦ, который выдает TLS- сертификаты. Рассмотрим пример цепочки от запроса до ответа:
import org.rssys.gost.jca.spec.GostDerCodec;import org.rssys.gost.pkix.cert.GostCsrBuilder;import org.rssys.gost.pkix.cert.GostCsrParser;import org.rssys.gost.pkix.cert.GostExtensionParser;import java.util.Arrays;// ============================================================// CSR — заявитель формирует запрос, УЦ его обрабатывает// ============================================================// Сторона заявителя: генерируем ключ и CSRKeyPair applicantKp = KeyGenerator.generateKeyPair(params);byte[] csrDer = GostCsrBuilder.create( "CN=new-service.example.com,O=MyOrg,C=RU", applicantKp.getPublic()) .sanDns("new-service.example.com", "www.new-service.example.com") .keyUsage(GostCertificateBuilder.KeyUsage.DIGITAL_SIGNATURE) .sign(applicantKp.getPrivate());System.out.println("CSR сформирован, отправляем в УЦ:");System.out.println(GostCsrParser.fromDer(csrDer).toPem());// Сторона УЦ: CSR может прийти откуда угодно// Сторона УЦ: разбираем CSRGostCsrParser csr = GostCsrParser.fromDer(csrDer);// Proof-of-possession: доказывает, что заявитель владеет закрытым ключом,// соответствующим публичному ключу в CSR — а не просто скопировал чужой// открытый ключ в запрос от своего имени.if (!csr.verifySelf()) { // Библиотека сама залогирует WARNING с subject перед этим throw — // здесь не нужно дублировать сообщение, только решить, что делать дальше throw new IllegalArgumentException("CSR отклонён: proof-of-possession не прошёл");}System.out.println("PoP подтверждён, subject=" + csr.getSubjectDn());// УЦ решает, что из запроса принять как есть (SAN — обычно да),// а что назначить самостоятельно (issuer, срок действия, keyUsage —// заявитель не должен диктовать эти поля УЦ)GostExtensionParser.ExtensionsResult requested = csr.getExtensions();// УЦ выпускает сертификатGostCertificate issuedCert = GostCertificateBuilder.create(params, csr.getSubjectDn()) .publicKey(csr.getPublicKey()) .randomSerial() .notBeforeNow() .validForYears(1) .sanDns(requested.sanDnsNames) // из CSR .keyUsage(GostCertificateBuilder.KeyUsage.DIGITAL_SIGNATURE) // назначает УЦ, не заявитель .extendedKeyUsage(GostOids.EXT_SERVER_AUTH) // тоже назначает УЦ, не заявитель .issuerDn(intermediateDn) .sign(intermediateKp.getPrivate());System.out.println("Сертификат выпущен: " + issuedCert.getSubjectDn());// Ответ уходит обратно заявителю// Сторона заявителя: получили сертификат, проверяем, что он "наш"boolean sameKey = Arrays.equals( GostDerCodec.subjectPublicKeyPointBytes(issuedCert.getPublicKey()), GostDerCodec.subjectPublicKeyPointBytes(applicantKp.getPublic()));if (!sameKey) { throw new IllegalStateException("УЦ вернул сертификат с чужим ключом!");}//Сертификат валиден?ChainValidator.validateChain( List.of(issuedCert, intermediateCert, rootCert), List.of(rootCert.getPublicKey()));System.out.println("Сертификат валиден, содержит наш ключ — принимаем");
TLS 1.3: сервер и клиент
Библиотека содержит исчерпывающую документацию, как поднять TLS 1.3 для Jetty 12, Tomcat 11, Undertow 2, Spring boot 3.4, Netty 4.2. Также есть статья в документации к библиотеке как поднять mTLS 1.3 для всей цепочки: клиент + Angie + Java server. Рассмотрим простой пример организации TLS 1.3 между клиентом и сервером.
import org.rssys.gost.api.KeyGenerator;import org.rssys.gost.api.KeyPair;import org.rssys.gost.jsse.GostSsl;import org.rssys.gost.jsse.GostSslException;import org.rssys.gost.jsse.crl.CrlPolicy;import org.rssys.gost.jsse.crl.JdkHttpCrlFetcher;import org.rssys.gost.jsse.ocsp.JdkHttpOcspFetcher;import org.rssys.gost.pkix.cert.GostCertificate;import org.rssys.gost.pkix.cert.GostCertificateBuilder;import org.rssys.gost.pkix.cert.GostPkcs12Builder;import javax.net.ssl.SSLContext;import javax.net.ssl.SSLServerSocket;import javax.net.ssl.SSLSocket;import java.security.cert.X509Certificate;// ============================================================// Подготовка: сертификат под localhost + сохранение в PKCS12// ============================================================// Для сетевой демонстрации нужен сертификат с SAN=localhost.// serverCert из PKI-главы выпущен на example.com — переиспользуем тот же// ключ serverKp, но перевыпускаем сертификат под localhost. В проде здесь// был бы реальный домен, а не localhost.GostCertificate localhostCert = GostCertificateBuilder.create(params, "CN=localhost,O=MyOrg,C=RU") .publicKey(serverKp.getPublic()) .randomSerial() .notBeforeNow() .validForYears(1) .sanDns("localhost") .keyUsage(GostCertificateBuilder.KeyUsage.DIGITAL_SIGNATURE) .issuerDn(intermediateDn) .sign(intermediateKp.getPrivate());// .certificate(certData, keyData) (голый DER) несёт только один// сертификат — подойдет, только если сертификат сервера подписан root'ом.// У нас intermediate CA, поэтому нужен PKCS12: caCertificate() —// аккумулятор, добавляет обе ступени цепочки в контейнер, который// сервер целиком отдаст клиенту во время handshake.byte[] serverPfx = GostPkcs12Builder.create() .key(serverKp.getPrivate()) .certificate(localhostCert) .caCertificate(intermediateCert) .caCertificate(rootCert) .password("changeit".toCharArray()) .friendlyName("server-cert") .build();// ============================================================// Сервер: PKCS12 + CA-доверие + автоматическая проверка CRL// ============================================================SSLContext serverCtx = GostSsl.builder() .certificate(serverPfx, "changeit".toCharArray()) .trustCa(rootCert.getEncoded()) .crlPolicy(CrlPolicy.IF_CDP_PRESENT) // Автоматика по загрузе и использованию CRL .crlFetcher(new JdkHttpCrlFetcher()) .buildServerContext();try (SSLServerSocket serverSocket = GostSsl.serverSocket(8443, serverCtx)) { Thread.ofVirtual().start(() -> { try (SSLSocket accepted = (SSLSocket) serverSocket.accept()) { accepted.startHandshake(); System.out.println("Соединение принято: " + accepted.getSession().getCipherSuite()); } catch (Exception e) { e.printStackTrace(); } }); // ============================================================ // Клиент: проверка сервера // ============================================================ SSLContext clientCtx = GostSsl.builder() .trustCa(rootCert.getEncoded()) .buildClientContext(); try (SSLSocket socket = GostSsl.socket("localhost", 8443, clientCtx)) { System.out.println("Handshake завершён: " + socket.getSession().getProtocol()); } // ============================================================ // Клиент: та же проверка, но с явной автоматикой OCSP/CRL // ============================================================ // Тот же механизм, что был в PKI-главе, только теперь срабатывает // автоматически внутри handshake, а не по ручному вызову // CrlVerifier/OcspVerifier — разработчику достаточно один раз // объявить политику при сборке контекста. SSLContext clientCtxWithRevocationCheck = GostSsl.builder() .trustCa(rootCert.getEncoded()) .ocsp(true) // ждать OCSP staple в handshake .ocspFetcher(new JdkHttpOcspFetcher()) // fallback, если сервер staple не прислал .crlPolicy(CrlPolicy.IF_CDP_PRESENT) .crlFetcher(new JdkHttpCrlFetcher()) // SSRF-защита .buildClientContext(); try (SSLSocket socket = GostSsl.socket("localhost", 8443, clientCtxWithRevocationCheck)) { System.out.println("Handshake с проверкой отзыва завершён: " + socket.getSession().getProtocol()); } // Если у пира отозван сертификат — handshake просто не завершится}// ============================================================// Взаимная аутентификация (mTLS)// ============================================================// Клиентский сертификат — issuedCert из CSR-главы, тоже выпущен// intermediate CA, поэтому клиенту тоже нужна PKCS12 сохранение с цепочкой.byte[] clientPfx = GostPkcs12Builder.create() .key(applicantKp.getPrivate()) .certificate(issuedCert) .caCertificate(intermediateCert) .caCertificate(rootCert) .password("changeit".toCharArray()) .friendlyName("client-cert") .build();// Контекст для сервераSSLContext mtlsServerCtx = GostSsl.builder() .certificate(serverPfx, "changeit".toCharArray()) .trustCa(rootCert.getEncoded()) .needClientAuth(true) // без этого клиентский сертификат не запрашивается вообще .buildServerContext();// Контекст для клиентаSSLContext mtlsClientCtx = GostSsl.builder() .certificate(clientPfx, "changeit".toCharArray()) .trustCa(rootCert.getEncoded()) .buildClientContext();// Сервер: цикл обработки соединенийtry (SSLServerSocket mtlsServerSocket = GostSsl.serverSocket(8444, mtlsServerCtx)) { Thread.ofVirtual().start(() -> { try (SSLSocket accepted = (SSLSocket) mtlsServerSocket.accept()) { accepted.startHandshake(); var peerCerts = accepted.getSession().getPeerCertificates(); System.out.println("Клиент проверен: " + ((X509Certificate) peerCerts[0]).getSubjectX500Principal()); } catch (Exception e) { e.printStackTrace(); } }); // Клиент соединяется с сервером try (SSLSocket socket = GostSsl.socket("localhost", 8444, mtlsClientCtx)) { System.out.println("mTLS handshake завершён: " + socket.getSession().getProtocol()); }}// ============================================================// Fail-closed примеры// ============================================================// Негативный кейс: клиент доверяет чужому корню KeyPair otherRootKp = KeyGenerator.generateKeyPair(params);byte[] otherRootDer = GostCertificateBuilder.create(params, "CN=Other Root,O=OtherOrg,C=RU") .publicKey(otherRootKp.getPublic()) .randomSerial().notBeforeNow().validForYears(1) .basicConstraints(true, null) .sign(otherRootKp.getPrivate()) .getEncoded();SSLContext wrongTrustCtx = GostSsl.builder().trustCa(otherRootDer).buildClientContext();try (SSLSocket socket = GostSsl.socket("localhost", 8443, wrongTrustCtx)) { System.out.println("Не должно было подключиться!");} catch (GostSslException e) { System.out.println("Отклонено, как и ожидалось: " + e.getClass().getSimpleName() + " (" + e.getCause().getClass().getSimpleName() + ")"); // GostSslException (SSLHandshakeException)}// Негативный кейс 2: mTLS, клиент без сертификата, сервер его требуетSSLContext noCertClientCtx = GostSsl.builder().trustCa(rootCert.getEncoded()).buildClientContext();try (SSLSocket socket = GostSsl.socket("localhost", 8444, noCertClientCtx)) { System.out.println("Не должно было подключиться!");} catch (GostSslException e) { System.out.println("Отклонено, как и ожидалось: " + e.getClass().getSimpleName() + " (" + e.getCause().getClass().getSimpleName() + ")");}
Шифрование и подпись документов
Если вам необходимо зашифровать или подписать документ, то для этих задач в библиотеке есть реализация соответствующих RFC для ГОСТ алгоритмов.
Базовая CMS-подпись документа
Это пример CMS SignedData (RFC 5652 + RFC 7091) с подписью ГОСТ Р 34.10-2012 и хэшированием Стрибог-256 (ГОСТ Р 34.11-2012):
import org.rssys.gost.pkix.cms.CmsSignedDataBuilder;import org.rssys.gost.pkix.cms.CmsSignedDataVerifier;import org.rssys.gost.pkix.cms.VerifiedSignedData;import org.rssys.gost.pkix.cert.PkixException;import java.nio.charset.StandardCharsets;import java.util.Arrays;// ============================================================// CMS SignedData — подпись документа// ============================================================byte[] document = "Договор №42 от 01.01.2026".getBytes(StandardCharsets.UTF_8);// --- Инкапсулированная подпись: данные внутри CMS-контейнера ---// Подписываем той же identity, что и в примере TLS-сервера в прошлой главе —// serverKp/localhostCert. .addCertificate(intermediateCert) обязателен:// без него верификатор увидит в CMS только один сертификат (leaf),// не найдёт издателя и цепочка оборвётся на INCOMPLETE_CHAIN.byte[] signedEncapsulated = CmsSignedDataBuilder.create() .data(document) .addSigner(serverKp.getPrivate(), localhostCert) .addCertificate(intermediateCert) .build();VerifiedSignedData result = CmsSignedDataVerifier.verifyAny(signedEncapsulated, rootCert);System.out.println("Подписант: " + result.signerCertificate().getSubjectDn());System.out.println("Данные совпадают: " + Arrays.equals(document, result.data()));// --- Detached-подпись: сам документ остаётся снаружи ---// Нужна, когда документ должен читаться системой, не понимающей CMS —// например, XML документ, а CMS рядом лежит отдельным .sig-файлом.byte[] signedDetached = CmsSignedDataBuilder.create() .data(document) .addSigner(serverKp.getPrivate(), localhostCert) .addCertificate(intermediateCert) .detached(true) .build();VerifiedSignedData detachedResult = CmsSignedDataVerifier.verifyAny(signedDetached, rootCert);System.out.println("Detached: данные НЕ хранятся в CMS = " + detachedResult.data()); // null — данные не хранятся// Что будет, если забыть промежуточный сертификатbyte[] brokenChain = CmsSignedDataBuilder.create() .data(document) .addSigner(serverKp.getPrivate(), localhostCert) // .addCertificate(intermediateCert) — забыли .build();try { CmsSignedDataVerifier.verifyAny(brokenChain, rootCert); System.out.println("Не должно было пройти!");} catch (PkixException e) { System.out.println("Отклонено: " + e.reason()); // INCOMPLETE_CHAIN}
CMS-шифрование документа
Ниже пример шифрования документа CMS EnvelopedData (RFC 5652) с шифрованием Кузнечик CTR-ACPKM (ГОСТ Р 34.12-2015) и обёртыванием сессионного ключа через VKO ГОСТ Р 34.10-2012 / KExp15 согласно RFC 9189.
import org.rssys.gost.pkix.cms.CmsEnvelopedDataBuilder;import org.rssys.gost.pkix.cms.CmsEnvelopedDataDecryptor;import org.rssys.gost.pkix.cms.CmsKeyWrap;import org.rssys.gost.pkix.cms.Kexp15CmsKeyWrap;import org.rssys.gost.pkix.cert.PkixException;import java.nio.charset.StandardCharsets;import java.util.Arrays;// ============================================================// CMS EnvelopedData — шифрование для получателя// ============================================================byte[] document = "Реквизиты счёта: ...".getBytes(StandardCharsets.UTF_8);// CmsKeyWrap — stateless-объект алгоритма обёртывания ключа,// не содержит секретов. В зашифрованный результат попадает только// его OID ("1.2.643.7.1.1.7.2.1") — отправитель и получатель создают// свой экземпляр независимо, сам объект по сети не передаётся.CmsKeyWrap keyWrap = new Kexp15CmsKeyWrap();// --- Шифрование: используем открытый ключ получателя ---// Внутри: генерируется случайный CEK (ключ шифрования данных),// документ шифруется на нём Кузнечиком, а сам CEK оборачивается// через VKO ГОСТ Р 34.10-2012 на открытом ключе issuedCert.byte[] enveloped = CmsEnvelopedDataBuilder.create() .data(document) .addRecipient(issuedCert) // при шифровании нужен только сертификат получателя .keyWrap(keyWrap) .build();// --- Расшифрование: используем закрытый ключ получателя ---byte[] decrypted = CmsEnvelopedDataDecryptor.decrypt( enveloped, applicantKp.getPrivate(), issuedCert, keyWrap);System.out.println("Расшифровано верно: " + Arrays.equals(document, decrypted));// --- С имитовставкой: для контроля целостности документа ---// .withOmac() добавляет проверку подлинности (Encrypt-then-MAC) —// без него шифрование обеспечивает конфиденциальность, но не от подмены данныхbyte[] envelopedWithMac = CmsEnvelopedDataBuilder.create() .data(document) .addRecipient(issuedCert) // сертификат получателя .keyWrap(keyWrap) .withOmac() .build();// Подмена одного байта должна обнаруживать подмену данныхbyte[] tampered = Arrays.copyOf(envelopedWithMac, envelopedWithMac.length);tampered[tampered.length / 2] ^= 0xFF;try { CmsEnvelopedDataDecryptor.decrypt(tampered, applicantKp.getPrivate(), issuedCert, keyWrap); System.out.println("Не должно было пройти!");} catch (PkixException e) { System.out.println("Подмена обнаружена: " + e.reason()); // MAC не совпал — fail-closed}
Шифрование + подпись документа
Для операций подписи и шифрования документов порядок вложенности даёт разные гарантии: «аудит без расшифрования» или «скрытый подписант».
Отдельного стандарта на комбинацию подписи и шифрования нет — это тот же RFC 5652, применённый дважды: один ContentInfo становится содержимым другого. Алгоритмическая часть внутри CMS (ГОСТ-подпись — RFC 4490, ГОСТ-шифрование — RFC 9189) остаётся такой же, как в предыдущих примерах — просто использована одновременно, в двух слоях одной структуры.
import org.rssys.gost.pkix.cms.CmsSignedAndEnvelopedData;import org.rssys.gost.pkix.cms.CmsSignedDataVerifier;import org.rssys.gost.pkix.cms.VerifiedSignedData;import java.nio.charset.StandardCharsets;import java.util.Arrays;// ============================================================// signThenEncrypt vs encryptThenSign — порядок меняет гарантии// ============================================================byte[] contract = "Условия сделки: ...".getBytes(StandardCharsets.UTF_8);// --- sign-then-encrypt: SignedData внутри EnvelopedData ---// Подпись скрыта от постороннего наблюдателя. Перехвативший CMS-блоб// без закрытого ключа получателя не узнает даже, КТО подписал документ и не увидит сам документ.byte[] signedThenEncrypted = CmsSignedAndEnvelopedData.signThenEncrypt( contract, serverKp.getPrivate(), localhostCert, issuedCert);// signThenEncrypt не встраивает intermediateCert в SignedData —// поэтому для сборки цепочки его нужно передать явно, вместе с root,// как доверенные CA (не сам localhostCert — это конечный сертификат,// не издатель).VerifiedSignedData r1 = CmsSignedAndEnvelopedData.decryptAndVerify( signedThenEncrypted, applicantKp.getPrivate(), issuedCert, intermediateCert, rootCert);System.out.println("sign-then-encrypt: " + r1.signerCertificate().getSubjectDn());// --- encrypt-then-sign: EnvelopedData внутри SignedData ---// Подпись видна снаружи. Кто угодно может проверить авторство,// не имея закрытого ключа для расшифрования. Сам документ не виден.byte[] encryptedThenSigned = CmsSignedAndEnvelopedData.encryptThenSign( contract, serverKp.getPrivate(), localhostCert, issuedCert);VerifiedSignedData r2 = CmsSignedAndEnvelopedData.verifyAndDecrypt( encryptedThenSigned, applicantKp.getPrivate(), issuedCert, intermediateCert, rootCert);System.out.println("encrypt-then-sign: " + Arrays.equals(contract, r2.data()));// Демонстрация "аудита без ключа": внешний слой encrypt-then-sign — обычный// SignedData, его можно проверить тем же CmsSignedDataVerifier,// вообще без закрытого ключа получателя.VerifiedSignedData outerOnly = CmsSignedDataVerifier.verifyAny( encryptedThenSigned, intermediateCert, rootCert);System.out.println("Подписант виден снаружи: " + outerOnly.signerCertificate().getSubjectDn());
Долговременная подпись BES→T→C→X-Long
Как сделать электронную подпись документа доказуемой не только сейчас, но и через годы? Юридически значимые документы (договоры, налоговая отчётность) должны оставаться доказуемыми годами или десятилетиями, а вся поддерживающая инфраструктура (сертификаты, CRL-серверы, OCSP-респондеры, даже сами TSA) — по природе своей временная.
Для этого существует лестница: BES→T→C→X-Long.
BES (Basic Electronic Signature) — это по сути то же самое, что мы делали в примере выше, плюс вызов .withCAdES(). BES доказывает: «этот документ подписан тем, у кого есть вот этот закрытый ключ». Но не доказывает «когда».
Проблема: если через полгода ключ подписанта скомпрометируют (украдут, или сам подписант заявит «у меня ключ украли месяц назад, эта подпись не моя») — по одной только BES-подписи невозможно доказать, что подпись была поставлена до компрометации, а не после ей же самой, задним числом. Юридически это слабая подпись.
T (Timestamp) — решает проблему «когда«. Независимая третья сторона (TSA — служба штампов времени) ставит собственную подпись поверх хэша подписи документа, удостоверяя: «эта конкретная подпись существовала уже в такой-то момент времени». Теперь можно доказать, что подпись стоит раньше даты компрометации ключа.
Новая проблема: чтобы подпись была юридически чистой, проверяющий должен убедиться, что на момент подписи сертификат подписанта не был отозван — не «валиден ли он сейчас», а «был ли валиден тогда». Для этого нужны CRL/OCSP-данные, актуальные на дату подписи.
C (Complete) — встраивает не сами данные CRL/OCSP, а ссылки на них (хэши, указывающие, какие именно записи нужны).
Проблема: это всё ещё ссылки, а не сами данные — чтобы ими воспользоваться, нужно, чтобы исходный сервер CDP/OCSP всё ещё существовал и отвечал через 5-10 лет. Часто это не так — серверы выключают, домены истекают, инфраструктура УЦ меняется.
X-Long (Extended Long) — последний шаг: вместо ссылок прямо внутрь подписи вкладываются все необходимые данные — весь сертификат цепочки, весь CRL, весь OCSP-ответ, какими они были на момент подписи. Теперь подпись полностью самодостаточна: через 20 лет, когда исходный TSA/CDP/OCSP-сервер давно не отвечает, у проверяющего всё равно есть всё необходимое прямо внутри CAdES-блоба — не нужно никуда ходить в сеть.
Ниже приведен пример лестницы в коде:
import org.rssys.gost.pkix.cms.CAdESExtender;import org.rssys.gost.pkix.cms.CAdESSignerResult;import org.rssys.gost.pkix.cms.CmsSignedDataBuilder;import org.rssys.gost.pkix.cms.VerifiedCAdESData;import org.rssys.gost.pkix.cert.GostCrl;import org.rssys.gost.pkix.cert.GostCrlBuilder;import org.rssys.gost.pkix.cert.GostOcspResponse;import org.rssys.gost.pkix.cert.GostOcspResponseBuilder;import org.rssys.gost.pkix.tsp.JdkHttpTspTransport;import java.nio.charset.StandardCharsets;import java.nio.file.Files;import java.nio.file.Path;import java.util.Arrays;import java.util.List;// ============================================================// CAdES-BES -> CAdES-T -> CAdES-X Long Type 1// ============================================================// --- Шаг 1: CAdES-BES — базовая подпись ---byte[] document = "Договор №42, версия для долговременного хранения" .getBytes(StandardCharsets.UTF_8);byte[] cadesBes = CmsSignedDataBuilder.create() .data(document) .addSigner(serverKp.getPrivate(), localhostCert) .withCAdES() // signingCertificateV2 — привязка к конкретному сертификату .build();System.out.println("CAdES-BES: " + cadesBes.length + " байт");// --- Шаг 2: CAdES-T — метка времени от реального TSA ---String tsaUrl = "http://pki.tsaserver.ru/tsp2012/tsp.srf";GostCertificate sertumCa = GostCertificate.fromDer( Files.readAllBytes(Path.of("sertum-ca.der")));GostCertificate guc2022 = GostCertificate.fromDer( Files.readAllBytes(Path.of("guc2022.der")));GostCertificate[] tsaTrusted = {sertumCa, guc2022};byte[] cadesT = CAdESExtender.addTimestamp( cadesBes, tsaUrl, new JdkHttpTspTransport(), tsaTrusted);System.out.println("CAdES-T: " + cadesT.length + " байт (было " + cadesBes.length + ")");// --- Шаг 3: доказательства отзыва на момент подписи ---// CRL проверяет intermediateCert — его мог отозвать корень (rootCert).// CRL выпускается тем же CA, что выдал intermediateCert, — корнем.byte[] crlDer = GostCrlBuilder.create(rootCert.getSubjectDnBytes()) .thisUpdateNow() .nextUpdateDays(30) .withCrlNumber(1) .sign(rootKp.getPrivate());GostCrl crl = new GostCrl(crlDer);// OCSP проверяет localhostCert — его мог отозвать intermediate.// Ответ подписан тем же CA, что выдал сертификат, — intermediate.byte[] ocspDer = GostOcspResponseBuilder.create(localhostCert.getSerialNumber()) .issuerDn(intermediateCert.getSubjectDnBytes()) .good() .sign(intermediateKp.getPrivate());GostOcspResponse ocsp = new GostOcspResponse(ocspDer);// --- Шаг 4: CAdES-X Long Type 1 — материализуем всё для будущего ---// Списки crls/ocspList параллельны chain[i]: для каждого сертификата// цепочки — своё доказательство отзыва (или null, если не нужно).// chain[0] = localhostCert → ocspList[0] = OCSP от intermediate// chain[1] = intermediateCert → crls[1] = CRL от root// chain[2] = rootCert → оба null — trust anchor, не проверяетсяList<GostCertificate> chain = Arrays.asList(localhostCert, intermediateCert, rootCert);List<GostCrl> crls = Arrays.asList(null, crl, null);List<GostOcspResponse> ocspList = Arrays.asList(ocsp, null, null);// tspUrl=null — метка времени уже встроена на шаге 2, повторный вызов// TSP не нужен. embedXLongType1 пропустит addTimestamp при tspUrl==null.byte[] cadesXLong = CAdESExtender.embedXLongType1( cadesT, chain, crls, ocspList, null, null);System.out.println("CAdES-X Long: " + cadesXLong.length + " байт");// --- Шаг 5: верификация «через 10 лет» ---// Всё внутри: цепочка, CRL, OCSP, метка времени. Ни CDP, ни OCSP-респондер// не нужны — даже если к тому моменту инфраструктура УЦ давно не отвечает.VerifiedCAdESData result = CAdESExtender.verifyCAdESXLong( cadesXLong, rootCert, sertumCa, guc2022);System.out.println("Проверено: " + new String(result.data(), StandardCharsets.UTF_8));for (CAdESSignerResult sr : result.signers()) { System.out.println("Подписант: " + sr.signerCertificate().getSubjectDn()); System.out.println("Меток времени: " + sr.timestamps().size());}
Заключение
В процессе реализации различных частей библиотеки я не раз убедился, что криптография не терпит дилетантов. В ней миллионы нюансов, которые просто в голову не могут прийти рядовому разработчику. Мне стало очевидно, зачем требуются сертифицированные средства, которые проходят аудит и написаны людьми с соответствующей подготовкой. Ошибиться в криптографии легко, цена этому может быть высока. И тем не менее я решил создать открытый инструмент, который со временем может пройти обкатку на реальных проектах, приобрести необходимый уровень зрелости и надежности. Надеюсь вы найдете его полезным, хотя бы для целей тестирования.
Почему написал статью сейчас? По моему мнению зрелость библиотеки достигла того уровня, чтобы ей можно было пользоваться хотя бы для собственных проектов. Дальше только проверка временем и аудит сообщества. Чем больше посмотрят глаз на код библиотеки, тем будет больше гарантий и доверия.
Дальнейшее развитие библиотеки ожидается, когда появятся пост-квантовые алгоритмы и возможно, отдельный модуль DTLS 1.3.
Сама библиотека, код, тесты, примеры кода и документы могут служить неплохим подспорьем для тех кто изучает криптографию. Удачи в изучении!
ссылка на оригинал статьи https://habr.com/ru/articles/1060744/