Введение
Если вы Java-разработчик, которому для личных нужд нужно поднять TLS 1.3 на ГОСТ или подписать документ CAdES, создать свой тестовый УЦ — эта статья для вас. Все примеры в этой статье одинаково работают что на JVM 21, что на Android 12+ — тот же самый API, без единого отличия в коде.
crypto-gost — реализация открытых стандартов ГОСТ (RFC) на 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, без этого никуда. Из коробки, разработчику в большом количестве доступны только зарубежные алгоритмы шифрования и подписи. А реализация отечественного ГОСТа доступна в существенно ограниченном виде. В текущих условиях стандарты ГОСТ в области криптографии стали актуальнее по многочисленным причинам. К таким причинам можно отнести недавний запрет на выдачу SSL/TLS- сертификатов для доменной зоны .ru от зарубежных компаний. Или привнесенные уязвимости в пакетах и библиотеках к популярным языкам программирования. В текущих условиях нет невозможности доверять программным средствам использующим зарубежную криптографию из-за отсутствия контроля над ними.
Какие пути есть у Java-разработчика по использованию ГОСТ для этих целей?
Bouncycastle. Этот вариант несет все риски использования зарубежных технологий, необходимость контроля обновлений. Библиотека развивает реализацию алгоритмов и стандартов ГОСТ по остаточному принципу. В качестве примера развития ГОСТ по остаточному принципу приведу CVE-2025-14813 для режима шифрования CTR. Атакующий может восстановить открытый текст без знания ключа. Уязвимы все версии BC-JAVA от 1.59 до 1.83; Реализация отечественных стандартов и RFC идет с явным запозданием и непонятной степенью чистоты реализации.
О дополнительных рисках использования Bouncycastle именно для ГОСТ-алгоритмов я привел главу в документации к проекту.OpenSSL+gost-engine+JNI. Этот вариант несет для Java-разработчика ад сборки и платформенной зависимости, несовместимости разных версий 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 secrets in 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-зависимость библиотеки к проекту.
Сравните это с тем, как сейчас у вас делается в проектах? Сколько пререквизитов и инструкций по настройке окружения вы должны выполнить, прежде чем получить аналогичный результат?
Больше примеров кода по PKIX инфраструктуре ГОСТ можно найти в репозитории.
Генерация ключей и сертификатов
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. Серверный сертификат — подписан промежуточным CA KeyPair 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-trip System.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(); // CRLDistributionPoints String[] 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 — заявитель формирует запрос, УЦ его обрабатывает // ============================================================ // Сторона заявителя: генерируем ключ и CSR KeyPair 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 может прийти откуда угодно // Сторона УЦ: разбираем CSR GostCsrParser 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()); }
Заключение
В процессе реализации различных частей библиотеки я не раз убедился, что криптография не терпит дилетантов. В ней миллионы нюансов, которые просто в голову не могут прийти рядовому разработчику. Мне стало очевидно, зачем требуются сертифицированные средства, которые проходят аудит и написаны людьми с соответствующей подготовкой. Ошибиться в криптографии легко, цена этому может быть высока. И тем не менее я решил создать открытый инструмент для себя, который со временем может пройти обкатку на личных проектах, приобрести необходимый уровень зрелости и надежности. Надеюсь вы найдете его полезным, хотя бы для академических целей.
Почему я написал эту статью? Библиотека может представлять академический интерес, т.к. при реализации кода я столкнулся со множеством неочевидных уязвимостей ( обнаружить которые мне помогли различные автоматизированные средства) и нашел пути их устранения. Много ловушек может ожидать разработчиков, если они захотят повторить эксперимент по реализации: в парсерах, цепочках проверки доверия, организации запросов, математике с большими числами.
Разработчикам и специалистам по информационной безопасности могут быть интересны подходы к fuzzing-тестированию для поиска уязвимостей, защита от DoS, методики и подходы к кросс-функциональному тестированию, фиксации архитектурных решений и модели угроз.
Начинающим математикам могут быть интересны подходы по умножению чисел и приемы организации защищенных свойств кода в среде исполнения. По моему мнению зрелость библиотеки достигла того уровня, чтобы ей можно было пользоваться для личных проектов. Дальше только проверка временем и аудит сообщества. Чем больше посмотрят глаз на код библиотеки, тем будет больше гарантий и доверия к коду для меня как автора.
Дальнейшее развитие библиотеки ожидается, когда появятся открытые стандарты на пост-квантовые алгоритмы и возможно, отдельный модуль DTLS 1.3.
Сама библиотека, код, тесты, примеры кода и документы могут служить неплохим подспорьем для тех кто изучает ГОСТ. Удачи в изучении!
