Введение

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

  1. зашифровать файл / массив байт

  2. расшифровать файл / массив байт

  3. поставить «электронную подпись» на файл (обьясню, почему в кавычках) / массив байт

  4. проверить «электронную подпись» файла / массив байт

Массив байт присутсвует в требованиях, потому что часто приходится информацию хранить не в виде файлов, а виде массива байтов в каком‑нибудь хранилище, которое не файловая система( например специальное поле записи базы данных).

Шифрование

Сам термин шифрование описывать подробно не буду, так как он понятен. Само шифрование должно быть стойким. Это означает: расшифровка информации занимает огромное время, чтобы после расшифровки пропала ценность этой информации. Т.е теоретически расшифровать можно всё, вопрос стоит только в потраченном на этот процесс времени. В самой JAVA есть спецификации по этой теме: Java Cryptography Architecture (JCA) и Java Сryptography Extension (JCE) и удобные библиотеки, реализующие эти спецификации, например bouncycastle.

Существуют два основных алгоритма шифрования:

  • симметричный алгоритм шифрования (AES)

  • ассиметричный алгоритм шифрования (RSA)

В AES используется один ключ, в RSA два ключа(закрытый и публичный) и области применения этих алгоритмов разные.

Хэш-функция, сигнатура, электронная подпись

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

Свойства хэш‑функции:

  • фиксированный результат длины хэша, независимо от размера источника данных;

  • уникальный хэш, позволяющий определять изменение содержимого источника (при изменение даже одного бита в байте огромного документа хэш становится другим. На этом свойстве хэшей основываются все проверки неизменяемости документов);

Самые известные алгоритмы реализации хэш‑функций: MD5, SHA, KeydHashAlgorithm и др. MD5 в настоящее время для серьезных задач практически не используется, так как уязвим и имеет самую высокую вероятность среди подобных алгоритмов по получению одинаковых хеш‑сумм для разных объектов.

Реализация

Сервис будем реализовать на Kotlin и Micronaut. Далее в статье будут описания и мои личные субьективные предпочтения, которые совсем не претендуют на абсолютную правоту.

Почему Kotlin

Недавно пришлось исследовать реализацию ГОСТ‑подписей. Все примеры были на JAVA. Код, который я потом переписал на Kotlin, был раза в полтора‑два меньше по обьему кода на Java и, на мой взгляд, более «чистый». Еще раз напомню, что это моё личное предпочтение и прошу не начинать в комментариях «религиозных» дискусий на тему какой язык программирования лучше. Моё субьективное мнение — самый лучший язык програмирования — это С++ ?, но так как на нём очень немногие программисты умеют писать безопасные программы, были придуманы языки со сборщиками мусора за программистами ? Прошу извинить за небольшое отклонение от темы.

Возвращемся к Kotlin. У Kotlin масса незаметных удобств, к тому же он null‑безопасен как язык программирования из «коробки». Надеюсь этот раздел подвинет Java‑разработчиков «попробовать» Kotlin. В IntelliJ IDEA есть мастер, который великолепно умеет конвертировать Java в Kotlin.

Почему Micronaut

Столкнулся с огромной проблемой на Spring Boot при подготовке дистрибутивов, где сборка нативная (не байт‑код, а уже целевой исполняемый код). Наверное, многие слышали что у нас, любителей разных JVM теперь есть такая возможность. Например, GraalVM нам в помощь. В нативный вид приложения Spring Boot хоть и собираются, но частенько собираются чисто «символически», т. е. крашатся в разных местах во время своей работы. Попробуйте разные варианты сборки в нативный вид Spring Boot, работающие с БД на JPA. Спасибо глабольному использованию чудо техник рефлексии в SpringBoot. Кто еще не интересовался, полюбопытсвуйте как интерфейсы JPA без реализующих их классов волшебным образом имплементируются в классы и объекты в runtime SpringBoot.

Обнаружил, что у Micronaut нет этих проблем, потому что здесь архитектурно другой принцип. При необходимости можем получить и сборку в нативном коде, которая работает прямо из «коробки». Самые сложные конструкции работы с разными БД (PostgreSql, MySQL) тоже работают. При этом Micronaut почти близнец Spring Boot по конструкциям, всё интуитивно понятно. А такой структурированной документации с примерами и описаниями API я ранее не встречал нигде.

Ссылки по теме

https://www.baeldung.com/micronaut-vs-spring-boot

https://medium.com/@mohamed.enn/micronaut-vs-spring-boot-a-comparative-study-547841556bdb

https://medium.com/@chandanjena706/micronaut-bean-introspection-a497b4710a6e

Основная часть 

Для Micronaut есть соответсвующий инициализатор в трех видах: 1) командная строка 2) https://micronaut.io/launch 3) Плагины в средах разработки ( я использую IntelliJ IDEA)

Ссылка на рабочий проект в конце статьи.

Пример учебный, местами реализация может быть спорна. Например, сервис дает возможность генерировать ассиметричные ключи и потом по соответсвующей «ручке» их скачивать. В промышленных системах с приватными и секретными ключами так не работают.

Основные зависимости проекта создаются автоматически инициализатором:

dependencies {
    ksp("io.micronaut.data:micronaut-data-processor")
    ksp("io.micronaut:micronaut-http-validation")
    ksp("io.micronaut.openapi:micronaut-openapi")
    ksp("io.micronaut.serde:micronaut-serde-processor")
    ksp("io.micronaut.validation:micronaut-validation-processor")
    implementation("io.micronaut.reactor:micronaut-reactor-http-client")
    implementation("io.micronaut.kotlin:micronaut-kotlin-runtime")
    implementation("io.micronaut.serde:micronaut-serde-jackson")
    implementation("io.micronaut.validation:micronaut-validation")
    implementation("jakarta.validation:jakarta.validation-api")
    implementation("org.jetbrains.kotlin:kotlin-reflect:${kotlinVersion}")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlinVersion}")
    implementation("org.bouncycastle:bcprov-jdk18on:1.77")
    implementation("org.bouncycastle:bcpkix-jdk18on:1.77")
    compileOnly("io.micronaut:micronaut-http-client")
    compileOnly("io.micronaut.openapi:micronaut-openapi-annotations")
    runtimeOnly("ch.qos.logback:logback-classic")
    runtimeOnly("com.fasterxml.jackson.module:jackson-module-kotlin")
    runtimeOnly("org.yaml:snakeyaml")
    testImplementation("io.micronaut:micronaut-http-client")
    testImplementation("org.mockito:mockito-core")
    testImplementation("org.assertj:assertj-core:3.25.3")
}

Обратите внимание, что для работы с криптографией используется библиотеки bouncycastle. Bouncycastle имеет реализацию и наших ГОСТ-овских криптоалгоритмов.

Проект выглядит так:

Скриншот проекта
Скриншот проекта

application.yml:

Для ассимитричных алгоритмов интерфейс:

interface CryptoAsymmetricSigner {
    fun generateKeyPair()
    fun getPublicKey(): PublicKey
    fun getPrivateKey(): PrivateKey
    fun exportKeyPair(fileKeyPem: String, filePublicPem: String)
    fun exportKeyPair(fosKeyPem: FileOutputStream, fosPublicPem: FileOutputStream)
    fun exportPublicKey(fosPublicPem: FileOutputStream)
    fun exportPrivateKey(fosKeyPem: FileOutputStream)
    fun importKeyPair(fileKeyPem: File?, filePublicPem: File?)
    fun importKeyPair(private: ByteArray?, public: ByteArray?)
    fun importPrivateKey(fileKeyPem: File)
    fun importPrivateKey(private: ByteArray)
    fun importPublicKey(filePublicPem: File)
    fun importPublicKey(public: ByteArray)
    fun encrypt(fis: FileInputStream, fos: FileOutputStream)
    fun encrypt(data: ByteArray): ByteArray
    fun decrypt(fis: FileInputStream, fos: FileOutputStream)
    fun decrypt(data: ByteArray): ByteArray
    fun sign(fis: FileInputStream, fos: FileOutputStream)
    fun sign(data: ByteArray): ByteArray
    fun verify(fis: FileInputStream, sig: FileInputStream): Boolean
    fun verify(data: ByteArray, sig: ByteArray): Boolean
}

Специфицировано:

  1. генерация ключевой пары

  2. экспорт разных комбинаций ключевой пары и элементов

  3. импорт разных комбинаций ключевой пары и элементов

  4. шифрование файла, массива в памяти

  5. дешифпования файла, массива в памяти

  6. получение сигнатуры(подписи) на файл, массив в памяти

  7. проверка сигнатуры(подписи) на файл, массив в памяти

    Обратите внимание на возможность «комбинаций». Нет необходимости иметь полную пару ключей. Для некоторый операций используется только публичный ключ (шифрование), только приватный ключ(расшифровка).

Для симетричных алгоритмов интерфейс:

interface CryptoSymmetricSigner {
    fun generateKey(size: Int, mode: ModeIv = ModeIv.SIMPLE): SecretKey
    fun getKey(): SecretKey?
    fun exportKey(fos: FileOutputStream)
    fun exportKeyToByteArray(): ByteArray
    fun importKey(fis: FileInputStream)
    fun importIvParameterSpec(bytes: ByteArray)
    fun import(keyFis: FileInputStream, ivFis: FileInputStream)
    fun import(keyBytes: ByteArray, ivBytes: ByteArray)
    fun exportIvParameterSpec(fos: FileOutputStream)
    fun exportIvParameterSpecToByteArray(): ByteArray
    fun importIvParameterSpec(fis: FileInputStream)
    fun importKey(bytes: ByteArray)
    fun encrypt(fis: FileInputStream, fos: FileOutputStream)
    fun encrypt(data: ByteArray): ByteArray
    fun decrypt(fis: FileInputStream, fos: FileOutputStream)
    fun decrypt(data: ByteArray): ByteArray
}

Специфицировано:

  1. генерация секретного ключа

  2. экспорт секретного ключа и IV

  3. импорт секретного ключа и IV

  4. шифрование файла, массива в памяти

  5. дешифпования файла, массива в памяти

Функция шифрования:

override fun encrypt(data: ByteArray): ByteArray {
        if (publicKey == null) {
            throw CryptoSignerException("The publicKey is uninitialized!")
        }
        val encryptCipher = Cipher.getInstance(DEFAULT_ALGORITHM)
        encryptCipher.init(Cipher.ENCRYPT_MODE, publicKey)
        return encryptCipher.doFinal(data)
    }

Функция дешифрования:

override fun decrypt(fis: FileInputStream, fos: FileOutputStream) {
        if (privateKey == null) {
            throw CryptoSignerException("The privateKey is uninitialized!")
        }
        val data = fis.readAllBytes()
        val decData = decrypt(data)
        fos.write(decData)
    }

Функция расчета подписи/сигнатуры:

override fun sign(data: ByteArray): ByteArray {
        if (privateKey == null) {
            throw CryptoSignerException("The privateKey is uninitialized!")
        }
        val signature = Signature.getInstance(DEFAULT_SIGNATURE_ALGORITHM)
        signature.initSign(privateKey)
        signature.update(data)
        return signature.sign()
    }

Функция проверки подписи/сигнатуры:

override fun verify(data: ByteArray, sig: ByteArray): Boolean {
        if (publicKey == null) {
            throw CryptoSignerException("The publicKey is uninitialized!")
        }
        val signature = Signature.getInstance(DEFAULT_SIGNATURE_ALGORITHM)
        signature.initVerify(publicKey)
        signature.update(data)
        return signature.verify(sig)
    }

Реализация Controller-а 

В реализации контроллера, на мой взгляд, тоже много чего интересного. Особенно с входными и выходными стримами(файлами). Например, прием конкретно двух файлов на обработку (не массива файлов, а именно конкретного количества). И чтобы при этом «swagger» эту ситуацию корректно визуализировал. Для SpringBoot и его «swagger» довольно проблемная ситуация.

@Post(
        value = "/import/key-pair",
        consumes = [MediaType.MULTIPART_FORM_DATA],
        produces = [MediaType.TEXT_PLAIN]
    )
    @Operation(
        summary = "Import Key Pair",
        description = "Attention! Dangerous operation! Shown for educational purposes!"
    )
    @Tag(name = "import")
    fun importKeyPair(
        @Part("public") public: CompletedFileUpload,
        @Part("private") private: CompletedFileUpload
    ): HttpResponse<Any> {
        cryptoService.importKeyPair(private.inputStream.readAllBytes(), public.inputStream.readAllBytes())
        return HttpResponse.ok()
    }
@Post(
        value = "/verify-sign",
        consumes = [MediaType.MULTIPART_FORM_DATA],
        produces = [MediaType.APPLICATION_JSON]
    )
    @Operation(
        summary = "Verify file signature",
    )
    @Tag(name = "sign")
    fun verify(
        @Part("data") data: CompletedFileUpload,
        @Part("sig") sig: CompletedFileUpload
    ): HttpResponse<Boolean> {
        val verify = cryptoService.verify(data.bytes, sig.bytes)
        log.debug("verify is $verify")
        return HttpResponse.ok(verify)
    }

Возврат результата в виде выходного стрима:

@Post(
        value = "/sign-file",
        consumes = [MediaType.MULTIPART_FORM_DATA],
        produces = [MediaType.APPLICATION_OCTET_STREAM]
    )
    @Operation(
        summary = "Calculate file signature",
    )
    @Tag(name = "sign")
    fun sign(data: CompletedFileUpload): StreamedFile {
        val inputStream = cryptoService.sign(data.bytes).inputStream()
        return StreamedFile(inputStream, MediaType.APPLICATION_OCTET_STREAM_TYPE)
    }

Работа сервиса 

Запуск сервиса в обычном режиме:

Время старта приложения 1390 ms
Время старта приложения 1390 ms

Я откомпилировал приложение в нативный вид и упаковал в докер‑образ «pawga777/crypto:latest». Докер образ можете скачать из docker‑hub.

Запустим приложение:

Время старта приложения 85 ms
Время старта приложения 85 ms

Обратите внимание на разницу времени старта: 85 ms нативно откомплированной версии против 1390 ms JVM‑версии Micronaut. Впечатляет? Аналогичная версия на JVM‑версии SpringBoot стартует еще дольше (порядка 2000 ms), так как SpringBoot по своей «природе» медленнее Micronaut.

Давайте немного потестируем.

Зашифруем файл размеров 8 М

Шифруем файл чере web-версию swagger нашего приложения
Шифруем файл чере web-версию swagger нашего приложения

Аналогично, расшифруем:

curl -X 'POST' \
  'https://localhost:8443/crypto/symmetric-decrypt-file' \
  -H 'accept: application/octet-stream' \
  -H 'Content-Type: multipart/form-data' \
  -F 'data=@Cryptography_and_Cryptanalysis_in_Java.enc'
Работа сервиса по шифрованию файла и его дешифровке
Работа сервиса по шифрованию файла и его дешифровке

Cryptography_and_Cryptanalysis_in_Java.pdf — оригинал

Cryptography_and_Cryptanalysis_in_Java.enc — зашифрованный файл

Cryptography_and_Cryptanalysis_in_Java_enc.pdf — дешифрованная версия

Теперь давайте получим сигнатуру(электронную подпись) файла.

curl -X 'POST' \
  'https://localhost:8443/crypto/sign-file' \
  -H 'accept: application/octet-stream' \
  -H 'Content-Type: multipart/form-data' \
  -F 'data=@Cryptography_and_Cryptanalysis_in_Java.pdf;type=application/pdf'

Проверим полученную электронную подпись:

Проверка успешна. Размер подписи 256 байт
Проверка успешна. Размер подписи 256 байт

Эпилог

Код приложения можно скачать отсюда: код приложения на github.

Кому любопытно, посмотрите тесты из bouncycastle для гостовских криптоалгоритмов, тесты перевел на Kotlin и добавил в раздел «тесты» приложения (к самому приложению эти тесты отношение пока не имеют).

Если у кого будет время, или есть уже такая информация, а именно как «отторгнуть» гостовские криптоалгоритмы от их контейнерной среды (КРИПТО ПРО...) — поделитесь. На эту тему нашел интересную реализацию: esia‑crypto. Но повторить «подвиг» в виде «keystore.jks», в котором гостовские ключи хранятся в отдельном контейнере, мне не удалось.

Да, оставлю за скобками юридескую основу работы с гостовскими ключами, так как юридическая значимость работы с этими ключами появляется после удовлетворения ряда требований. У нас с вами просто исследовательский интерес по не очень тривиальной задаче.

По теме Micronaut планирую описать опыт работы с базами данных (PostgreSQL) в реактивном исполнении, в том числе и на корутинах. Все работает замечательно и в нативно откомпилированном виде, и в обычном. Но есть нюансы настроек такого «нативного» приложения для работы из среды докер‑образов. Постараюсь рассказать.

Всем прочитавшим до конца мою статью — спасибо.

Happy Coding!

Комментарии (5)


  1. itGuevara
    02.04.2024 07:42

    Да, оставлю за скобками юридическую основу работы с гостовскими ключами, так как юридическая значимость работы с этими ключами появляется после удовлетворения ряда требований. 

    Полагаю, что юридическая основа появится только тогда, когда компетентные органы сертифицируют bouncycastle или подобные open source библиотеки (причем и сами продукты на таких библиотеках), но это видимо не случится никогда (не будут они ломать монополизм своего же КриптоПро, т.е. рубить "сук на котором"). Использование ГОСТ - алгоритмов имеет смысл видимо только для получения квалифицированной электронной подписи.


  1. Pawga777 Автор
    02.04.2024 07:42

    Всё верно написали. КриптоПро - фактически монополист. Разработчики КриптоПро JCP в своем продукте используют bouncycastle (изучал их тесты в этой библиотеке, полюбопытсвуйте). По моему мнению, дело не в сертификации самой библиотеки а в сертификации "окружения". У самой КриптоПро - есть конкуренты, но слабые (не погружался в эту тему и не хочу никого обидеть. Фиксирую просто, что конкуренты есть). Можно сгенерить сертификаты в КриптоПро, выгрузить их. Можно настроить openssl для работы с гостовскими алгоритмами. Но как правило за пределами КриптоПро их сертификаты не работают. Их поддержка мне напрямую ответила что keystore они не поддерживают и не собираются. И скорее всего не будет работать и то, что вы сделаете за пределами самой КриптоПро и каким-то образом туда заимпортируете (например через openssl). Надо прилагать некие усилия (конвертировать платной утилитой или некие патчи накатывать). Я полюбопытствовал и не нашел "красивых" решений. Сама bouncycastle - красива :). С гостовскими алгоритмами можно рботать (см тесты которые я приложил), но по моим ощущениям всё совсем негибко. А просто использовать и шифровать данные RSA и AES (без ГОСТ) - легко и понятно.


    1. itGuevara
      02.04.2024 07:42

      Всё верно написали. КриптоПро - фактически монополист. 

      Видимо с кнопкой "Ответить" промазали. Это ведь к "Полагаю, что юридическая основа появится только тогда, ... " было.

      Библиотека bouncycastle используется в open source BPMS (СЭД и др.) https://runawfe.ru/

      см. стр. 250: http://conf-eekm.ru/wp-content/uploads/2024/02/Инжиниринг-предприятий.-Т.-1-3.pdf

      Однако именно невозможность сертификации (см. первый пост) подобного для использования КЭП - "ставит крест" на широком распространении простых и бесплатных (community edition, хотя есть и проф.) решений для юридически значимого документооборота, например, для кадрового электронного документооборота.


      1. Pawga777 Автор
        02.04.2024 07:42

        Сообщением выше кнопкой промахнулся. Нужно было "ответить" :)

        Опять не поспорю. Добавлю только, что для юридической значимости нужно соблюсти ряд требований. Не только иметь возможность использовать совершенные технологии криптоалгоритмов, но и соблюсти ряд условий.
        Например, для подписи использовать сертификат, выданный доверенным центром сертификации(мой самоподписанный сертификат только меня самого удовлетворит :) ). Но самое главное: подписать документ в среде, которая будет гарантировать и время подписания (синхронизируется с серверами времени извне и время подписи нельзя подделать) и самого владельца подписи. Тут-то и появляются практически безалтернативные среды, которые серифицированы. Подписи такие, упаковывают в специальные контейнеры. Если нужно гарантировать работу с такой подписью чере много лет то это, например CAdES-A v3.

        А вот внутри организации можно свои правила вводить. В тех же СЭД широко используется простая электронная подпись - если пользователь аутенфицирован, то его все действия подтверждены этим фактом, например, именно такой-то согласовал внутри организации такой-то документ в контексте конкретной СЭД. Но можно чуть-чуть улучшить алгоритмы: использовать не сигнатуру MD5, а более надежную сигнатуру подписи RSA. Конфидицальные документы шифровать для доступа определенной группы(чтобы текст документов был виден только этой группе, например членам правления, а не всем админам ПРОД-а, хоть они и подписывают документы о "неразглашении" :) ). Вот тогда примеры реализации, как в этой статье например, могут быть полезны разработчикам. А нужны ли гостовские алгоритмы для шифрования внутри оргнанизации без доступа извне со всей этой огромной конструкцией типа крипта-про - тоже вопрос. Кому-то точно не нужны.


        1. itGuevara
          02.04.2024 07:42

          Добавлю только, что для юридической значимости нужно соблюсти ряд требований. 

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

          Для долговременного хранения, например, 10 лет есть два варианта: TSP + длинный сертификат (скорее теория) и "TSP + OCSP", см. 3. Основной «подводный камень» КЭДО и иного ЭДО.

          А вот внутри организации можно свои правила вводить. В тех же СЭД широко используется простая электронная подпись - 

          Возможно тоже не совсем верно. Часто на простую подпись налагаются требования регуляторов, например, по КЭДО (ТК, 578н и др.), т.е. "свои правила" вводить не получится, если они противоречат законам, а законы, в т.ч. и №472 часто вообще не однозначны.