Привет, Хабр! Меня зовут Вадим, я Java-разработчик SimbirSoft. В этой статье я расскажу, как на одном из проектов мы реализовали возможность валидации электронной подписи с помощью КриптоПро JCP.
Этот фреймворк оказался хорошей альтернативой КриптоПро SVS после того, как последний попал под санкции Microsoft. Впоследствии на других подобных проектах я убедился в том, что решение рабочее и наиболее подходящее под бизнес-цели заказчиков. Прежде всего это банки, нанимающие организации и другие юрлица, где ведется электронный документооборот.
Наш клиент занимался переоборудованием автомобилей на газовое топливо. Для работы со своими заказчиками ему приходилось вызывать курьеров к каждому, чтобы подписать тот или иной документ. Это было крайне неудобно и затратно. Поэтому он обратился к нам, чтобы реализовать задачу валидации электронной подписи в короткий срок. Вместе с командой из двух разработчиков и одного тимлида нам удалось сделать это за один месяц.
Чтобы решить задачу, мы провели исследование возможных вариантов валидации подписи. Одним из подходящих вариантов стал сервис КриптоПро SVS. Это обертка над КриптоПро CSP, которая предоставляет веб-сервис REST и SOAP для проверки подписи, а также проверяет валидность и квалифицированность сертификата.
Но при его развертывании в облачной среде Yandex Cloud мы столкнулись с проблемой. В период разработки нашей системы в 2022 году Yandex Cloud перестал поддерживать виртуальные машины Windows в связи с санкциями от компании Microsoft. А сервис SVS подразумевал развертывание в данной операционной системе.
Перенос системы в другой облачный сервис был невозможен, так как вся инфраструктура уже была настроена и успешно работала, переезд занял бы много времени и денег заказчика, а эти ресурсы мы стараемся беречь. Поэтому было принято решение создать свой сервис для валидации detached подписей на основе библиотеки КриптоПро JCP. Она оказалась более низкоуровневой, но доступной для реализации.
О решении
КриптоПро JCP — средство криптографической защиты информации, реализующее российские криптографические стандарты. Разработано в соответствии со спецификацией JCA (Java Cryptography Architecture).
Для разработки своего сервиса валидации подписей важно понимать, какие бывают электронные подписи (ЭП). Начнем с принципа работы, который заключается в шифровании информации.
1.1 Ассиметричное шифрование
Существует два основных вида шифрования — симметричное и ассиметричное. Мы остановимся на ассиметричном, так как именно оно используется в электронных подписях. Рассмотрим, как работает классический вид ассиметричного шифрования.
В данном виде шифрования используется два ключа:
Открытый, который доступен всем, и с помощью которого можно зашифровать информацию.
Закрытый или секретный. Помимо электронных подписей, используется для безопасной передачи информации в интернете. Поэтому рассмотрим данный ключ на примере передачи сообщения.
Допустим, у нас есть два собеседника — Петя и Катя. Они работают на одном проекте, и естественно, на них распространяется NDA, который ни в коем случае нельзя нарушать. Поэтому Петя решает передать Кате код проекта в интернете с помощью ассиметричного шифрования.
Для этого:
Катя генерирует пару ключей: открытый и закрытый.
Катя передает Пете открытый ключ. Передача может осуществляться по незащищенному каналу.
Петя шифрует код проекта при помощи открытого ключа.
Петя передает Кате зашифрованный код. Передача может осуществляться по незащищенному каналу.
Катя расшифровывает полученную информацию с помощью закрытого ключа.
Важно отметить, что при такой схеме передачи нам не нужны защищенные каналы связи, так как перехват любых данных не имеет смысла, поскольку восстановить исходную информацию возможно только при помощи закрытого ключа, который есть только у Кати.
1.2 Ассиметричное шифрование в электронной подписи
Несмотря на то, что электронные подписи работают по алгоритму ассиметричного шифрования, всё происходит немного иначе. В примере, который мы рассмотрели выше, наше сообщение мог зашифровать любой пользовать, но расшифровать его мог только обладатель закрытого ключа. В случае с электронными подписями все по-другому. Зашифровать документ должен только один человек (обладатель подписи), поэтому мы будем шифровать на закрытом ключе, а вот проверить валидность подписи уже может любой. На сайте удостоверяющего центра, как правило, можно скачать открытый ключ проверки, хеш которого должен совпадать с хешем открытого ключа владельца. Таким образом доказывается его достоверность.
Тут-то мы и подходим к валидации подписей. Как же это работает, и почему любой человек может проверить ее законность и корректность?
1.3 Валидация подписи
Подписи бывают двух видов — отсоединенная, или detached, и присоединенная, или attached.
Главное отличие этих подписей заключается в том, что при создании присоединенной подписи формируется один файл, который содержит и саму подпись, и документ. А при создании отсоединенной подписи формируется отдельный файл с расширением .sign
, который как раз и содержит detached-подпись.
Для валидации обоих типов подписи используется одна логика. Сертификат электронной подписи пользователя связан с сертификатом удостоверяющего центра, который выдал ему эту подпись, а сертификат удостоверяющего центра связан с корневым сертификатом. Все сертификаты удостоверяющих центров и корневых сертификатов находятся в открытом доступе на портале УФО.
По сути, для валидации подписи мы проходим по цепочке сертификатов, и если все они являются доверенными, то подпись является валидной.
1.4 Реализация валидации на Kotlin c использованием JCP
1.4.1 Импорт корневых сертификатов
Начнем с того, что для валидации ЭП нам необходимы корневые и промежуточные сертификаты, а также список отозванных сертификатов. Список корневых сертификатов, необходимо скачать с портала УФО и установить в Java-машину.
Для Java есть стандартное хранилище доверенных CA-сертификатов (cacerts), которое используется для Java-приложений и является составной частью среды выполнения приложения — JRE (Java Runtime Environment). Оно располагается в директории jre/lib/security/cacerts
, именно туда и нужно установить все корневые сертификаты с помощью следующей команды.
keytool -importcert -cacerts -storepass
"changeit" -file путь_до_сертификата.cer -alias
"название_сертификата_в_хранилище"
Alias для сертификата можно придумывать любой!
1.4.2 Валидация detached подписи на Kotlin
Так как detaсhed-подпись является отдельным файлом с расширением .sign
, для валидации нам понадобится еще и исходный файл, который подписывался.
Мы поступили следующим образом:
файл с
.sign
мы переводили в base64 и передавали в сервис валидации как Stringисходный документ передавали как байтовый поток с типом ByteArray
fun validateDetachedCadesSign(signature: String, source: ByteArray){
Security.addProvider(JCP())
Security.addProvider(RevCheck())
System.setProperty("com.sun.security.enableCRLDP", "true")
System.setProperty("com.ibm.security.enableCRLDP", "true")
System.setProperty("ru.CryptoPro.reprov.enableAIAcaIssuers", "true")
System.setProperty("com.sun.security.enableAIAcaIssuers", "true");
val signStream: InputStream = ByteArrayInputStream(Base64.decode(signature.toByteArray()))
val fileStream: InputStream = ByteArrayInputStream(source)
val cadesSignature = CAdESSignature(signStream, fileStream, null)
cadesSignature.verify(null)
}
Проперти, которые мы устанавливаем, указывают на то, что отозванные сертификаты будут искаться в интернете (этот участок кода взят из документации JCP).
1.4.3. Получение информации об электронной подписи
Помимо того, что мы можем просто проверить электронную подпись с помощью JCP, мы можем получить массу информации о ней. Например, ФИО ее владельца, ИНН, ОГРН (если это юрлицо), номер сертификата и многое другое. Все это хранится в объекте cadesSignature, который мы создали для валидации. Мы использовали эти данные для создания штампов в подписанных документах.
val signer = cadesSignature.cAdESSignerInfos
val cert = X509CertImpl(signer[0].signerCertificate.encoded)
С помощью данного кода мы можем получить сертификат, а из него уже извлечь нужные нам данные:
cert.serialNumber – номер сертификата
cert.notBefore – дата выпуска сертификата
cert.notAfter – дата истечения сертификата
Остальные данные нам удалось получить слегка изощренным способом из-за того, что в классе X509CertImpl ИНН, ОГРН и ФИО не выделены в отдельные переменные. Все эти данные хранятся в поле subjectX500PrincipalInternal нашего объекта cert, поэтому мы преобразовали его в строку и применили к ней ряд регулярных выражений.
ИНН и ОГРН
val innPatter = Pattern.compile("(?<=КПП=)[^,]*", Pattern.MULTILINE)
val ogrnPattern = Pattern.compile("(?<=ОГРН=)[^,]*", Pattern.MULTILINE)
Фамилия
val surnamePattern = Pattern.compile("(?<=SN=)[^,]*", Pattern.MULTILINE)
Имя, отчество
val giveNamePattern = Pattern.compile("(?<=G=)[^,]*", Pattern.MULTILINE)
Далее мы просто применили эти регулярные выражения к нашему subject и получили все необходимые данные для штампа электронной подписи.
fun getCertInfo(cadesSignature: CAdESSignature): CertInformationResponse{
val signer = cadesSignature.cAdESSignerInfos
val cert = X509CertImpl(signer[0].signerCertificate.encoded)
val serialNumber = cert.serialNumber
val subject = cert.subjectX500PrincipalInternal.toString()
log.info("Certificate subject = \n$subject")
val surnamePattern = Pattern.compile("(?<=SN=)[^,]*", Pattern.MULTILINE)
val giveNamePattern = Pattern.compile("(?<=G=)[^,]*", Pattern.MULTILINE)
val innPatter = Pattern.compile("(?<=КПП=)[^,]*", Pattern.MULTILINE)
val ogrnPattern = Pattern.compile("(?<=ОГРН=)[^,]*", Pattern.MULTILINE)
val matcherSurname = surnamePattern.matcher(subject)
val matcherGiveName = giveNamePattern.matcher(subject)
val matherInn = innPatter.matcher(subject)
val matherOgrn = ogrnPattern.matcher(subject)
var surname = ""
while (matcherSurname.find())
surname = subject.substring(matcherSurname.start(), matcherSurname.end())
var firstName = ""
var patronymic: String? = null
while (matcherGiveName.find()){
val giveName = subject.substring(matcherGiveName.start(), matcherGiveName.end())
val splitGiveName = giveName.split(" ")
firstName = splitGiveName[0]
if(splitGiveName.size > 1)
patronymic = splitGiveName[1]
}
var inn = ""
while (matherInn.find())
inn = subject.substring(matherInn.start(), matherInn.end())
var ogrn: String? = null
while (matherOgrn.find())
ogrn = subject.substring(matherOgrn.start(), matherOgrn.end())
return CertInformationResponse(
serialNumber = serialNumber,
ownerSurname = surname,
ownerFirstName = firstName,
ownerPatronymic = patronymic,
releaseDate = cert.notBefore,
expirationDate = cert.notAfter,
inn = inn,
ogrn = ogrn
)
}
1.4.4 Проблема получения информации из тестового сертификата
В ходе тестирования нашего решения мы использовали как реальные электронные подписи, так и тестовые сертификаты, которые можно легко выпустить на сайте Тестового удостоверяющего центра ООО "КРИПТО-ПРО". На реальных подписях все отрабатывало отлично, и мы получали всю необходимую информацию, но с тестовыми возникла проблема.
Дело в том, что при выпуске тестового сертификата электронной подписи в поле subjectX500PrincipalInternal хранится не так много информации, иногда оно может содержать всего одно значение. Вот реальный пример:
Поэтому для удобства тестирования мы создали интерфейс CertInfoProvider с методом получение информации, описанным выше. И создали две его имплементации CertInfoProviderProd, CertInfoProviderDev.
@Service
@ConditionalOnProperty(prefix = "test-cert", name = ["enable"], havingValue = "0")
class CertInfoProviderProd: CertInfoProvider {
override fun getCertInfo(cadesSignature: CAdESSignature): CertInformationResponse {
return super.getCertInfo(cadesSignature)
}
}
@Service
@ConditionalOnProperty(prefix = "test-cert", name = ["enable"], havingValue = "1")
class CertInfoProviderDev: CertInfoProvider {
override fun getCertInfo(cadesSignature: CAdESSignature): CertInformationResponse {
val signer = cadesSignature.cAdESSignerInfos
val cert = signer[0].signerCertificate
val serialNumber = cert.serialNumber
val testCert = "Test Center"
return if(cert.issuerX500Principal.toString().contains(testCert)) {
CertInformationResponse(
serialNumber = serialNumber,
ownerSurname = testCert,
ownerFirstName = testCert,
ownerPatronymic = testCert,
releaseDate = cert.notBefore,
expirationDate = cert.notAfter,
inn = testCert,
ogrn = testCert
)
} else{
super.getCertInfo(cadesSignature)
}
}
}
Таким образом, просто переключая параметр в конфиг мапе нашего сервиса мы могли очень гибко и быстро в случае необходимости включить, либо выключить валидацию тестовых сертификатов.
Результат
В ходе работы нам удалось прежде всего сберечь денежный и временной ресурс заказчика.
Переезд проекта в новый облачный сервис мог занять очень длительный срок, так как в команде не было DevOps-инженера, который бы смог заново настроить всю выстроенную инфраструктуру.
До реализации сервиса валидации электронных подписей заказчик тратил время и финансы на вызов курьеров к каждому клиенту, чтобы подписать документы. Теперь в этом нет необходимости.
Спасибо за внимание!
Больше авторских материалов для backend-разработчиков от моих коллег читайте в соцсетях SimbirSoft – ВКонтакте и Telegram.
mmMike
Просто для справки.
В Bouncy Castle есть функционал работы с гостовой подписью.
Signature.getInstance("GOST3411_2012_512withGOST3410_2012_512", "JCP");
и
Signature.getInstance("GOST3411WITHECGOST3410-2012-512", "BC");
Дадут одинаковый результат по использованию.
Одно "но". Сертификация. Операции с EC в JCP как бы проверены и сказано "это сертифицированное ПО". А те же самые операции в BC "не сертифицированы" (да еще и с открытым кодом).
Но хотя бы для тестов с покупкой/установкой лицензии на JСP возится не нужно.