Привет, Хабр!
Сегодня мы хотим поделиться решением интересной и новой для нас задачи: нужно встроить поддержу ЭЦП в мобильное приложение заказчика.
Основные принципы и тезисы
Электронная цифровая подпись — это криптографический механизм, который обеспечивает:
-
Подлинность: позволяет удостовериться, что отправитель является именно тем, за кого себя выдает. В мире физических документов это аналогично проверке подписи на бумаге, которую может поставить только конкретный человек.
-
Целостность: гарантирует, что данные не были изменены после подписания. Если документ был изменен после подписания, подпись станет недействительной.
-
Безотказность: автор подписи не может впоследствии отказаться от своих действий, утверждая, что он не подписывал документ.
Всё это достигается благодаря использованию парных ключей: открытого и закрытого. Закрытый ключ хранится на защищенных носителях, а открытый распространяется вместе с данными, которые он подписывает.
Закрытый или Приватный ключ равносилен личной подписи от руки.
На этапе подписи данные хэшируются (то есть превращаются в уникальную «цифровую подпись»), которая шифруется закрытым ключом отправителя. Получатель может использовать открытый ключ отправителя, чтобы расшифровать подпись и сверить хэш с вновь сгенерированным из полученных данных. Несовпадение укажет на изменение данных.
Простой пример: Вы отправляете коробку с вложенным списком содержимого и запечатанную лентой с уникальным рисунком. Получатель открывает коробку, сверяет содержимое со списком и убеждается, что лента не повреждена.
Поскольку только отправитель может использовать свой закрытый ключ, чтобы создать подпись, он не может отказаться от авторства подписанного сообщения или транзакции; подписанная информация связывается именно с их идентичностью через уникальный ключ.
Простой пример: Ваша подпись на бумажном документе в силе до тех пор, пока никто другой не смог её подделать, что крайне маловероятно, как и кража закрытого ключа при правильном его хранении.
Ключи и сертификаты:
-
Закрытый ключ — это секретная часть пары ключей, которым подпись создается и который должен храниться в максимально защищенных условиях.
-
Открытый ключ — это общедоступная информация, необходимая для проверки подписи, связанная с вашим закрытым ключом.
-
Сертификат — это документ, удостоверяющий связь открытого ключа с именно тем, кому он принадлежит, подтвержденный надежным удостоверяющим центром (ЦС). Сертификат помогает другим доверять вашему открытому ключу.
Эта система позволяет поддерживать безопасность и доверие в цифровой коммуникации, что критически важно для мобильных приложений, обрабатывающих чувствительные и юридически значимые данные.
Внедрение ЭЦП в приложение
Теперь, когда мы вспомнили основные термины и принципы, давайте перейдем к рассмотрению нашего кейса более детально. Бизнес-процесс подразумевает формирование неких документов в мобильном приложении с дальнейшей их отправкой на сервер и последующей обработкой. Поскольку документы без подписи не имеют юридической силы, пользователям заказчика приходилось распечатывать созданные документы для их подписания вручную. Вот этот процесс и было решено автоматизировать с целью экономии времени и ресурсов.
После выяснения всех обстоятельств, средством доставки сертификатов и приватного ключа был выбран носитель Рутокен Lite. Более детально со всем разнообразием подобных носителей и их отличий вы можете ознакомится на официальной странице техподдержки Рутокен. Провайдером же был выбран КриптоПро CSP. Такой выбор был сделан потому, что именно это сочетание обладает достаточной безопасностью, а также обеспечивает юридическую силу документов на территории Российской Федерации.
Для начала нам нужно организовать подключение к Рутокену. Делается это несложно. Сперва добавим библиотеку rtpcscbridge. Для этого пропишем такую зависимость в нашем build.gradle файле:
implementation 'ru.rutoken.rtpcscbridge:rtpcscbridge:1.2.0'
Также нам нужно проинициализировать зависимость. Для этого добавим следующие строки в метод onCreate() нашего Application,передадим контекст приложения и присоединим его к жизненному циклу:
RtPcscBridge.setAppContext(this) RtPcscBridge.getTransportExtension().attachToLifecycle(this, true)
Далее нам необходимо подключить и настроить криптопровайдер для осуществления криптографических действий. Конкретно в нашем случае, это было копирование контейнера с ЭЦП на устройство, чтобы пользователям не приходилось постоянно держать Рутокен подключенным к устройству. Главная цель — подписание pdf-файлов. Разомнем пальцы и приступим.
Для начала настоятельно рекомендуем внимательно ознакомится с информацией по ссылке.
Если кратко, то мы скачиваем с официального сайта упомянутые в руководстве инструменты и примеры. В принципе, демонстрационное приложение от КриптоПро достаточно хорошо показывает широкий функционал возможностей для реализации. Однако, на наш взгляд, оно уже серьезно устарело, да и написано на Java, а у нас весь проект на Kotlin. Поэтому в данной статье мы будем приводить примеры обновленных нами функций, а оригиналы вы знаете, где найти.
Наша первая задача после сборки и настройки проекта — это правильно проинициализировать библиотеки криптошифрования. После успешной инициализации проверим наличие уже сохраненных контейнеров на устройстве. Согласно архитектуре нашего приложения, осуществить это было удобнее всего в UseCase.
init { initCSPProviders() } class InitError @JvmOverloads constructor( val errorCode: Int, val errorMessage: String? = null ) { companion object { const val INIT_JAVA_PROVIDER_ERROR: Int = 0xff } } // Инициализация CSP провайдеров private fun initCSPProviders() { initCSPProvidersFuture = CompletableFuture.runAsync { val initCode = CSPConfig.init(context) if (initCode == CSPConfig.CSP_INIT_OK) { initJavaProviders(context, false) } initResult.postValue(InitError(initCode)) }.thenRun { val mainHandler = Handler(Looper.getMainLooper()) mainHandler.post { if (initResult.value?.errorCode == 0) { checkContainersOnDevice() } } }.exceptionally { throwable: Throwable -> Timber.e( throwable, "InitError(code = %s ,message = %s)", InitError.INIT_JAVA_PROVIDER_ERROR, throwable.message ) initResult.postValue( InitError( InitError.INIT_JAVA_PROVIDER_ERROR, throwable.message ) ) null } } private fun initJavaProviders(context: Context, useSSPITlsProvider: Boolean) { if (Security.getProvider(JCSP.PROVIDER_NAME) == null) Security.addProvider(JCSP()) Security.setProperty("ssl.KeyManagerFactory.algorithm", "GostX509") Security.setProperty("ssl.TrustManagerFactory.algorithm", "GostX509") Security.setProperty( "ssl.SocketFactory.provider", if (useSSPITlsProvider) "ru.CryptoPro.sspiSSL.SSLSocketFactoryImpl" else "ru.CryptoPro.ssl.SSLSocketFactoryImpl" ) Security.setProperty( "ssl.ServerSocketFactory.provider", if (useSSPITlsProvider) "ru.CryptoPro.sspiSSL.SSLServerSocketFactoryImpl" else "ru.CryptoPro.ssl.SSLServerSocketFactoryImpl" ) if (Security.getProvider("JTLS") == null) { if (useSSPITlsProvider) Security.addProvider(SSPISSL()) else Security.addProvider(ru.CryptoPro.ssl.Provider()) } cpSSLConfig.setDefaultSSLProvider(JCSP.PROVIDER_NAME) if (Security.getProvider(RevCheck.PROVIDER_NAME) == null) Security.addProvider(RevCheck()) System.setProperty("ru.CryptoPro.CAdES.validate_tsp", "false") System.setProperty("com.sun.security.crl.timeout", "5") System.setProperty("ru.CryptoPro.crl.read_timeout", "5") AdESConfig.setDefaultProvider(JCSP.PROVIDER_NAME) System.setProperty("xml_xxe_protected", "false") XmlInit.init() ResourceResolver.registerAtStart(XmlInit.JCP_XML_DOCUMENT_ID_RESOLVER) val xmlDSigRi: Provider = XMLDSigRI() Security.addProvider(xmlDSigRi) val provider = Security.getProvider("XMLDSig") if (provider != null) { provider["XMLSignatureFactory.DOM"] = "ru.CryptoPro.JCPxml.dsig.internal.dom.DOMXMLSignatureFactory" provider["KeyInfoFactory.DOM"] = "ru.CryptoPro.JCPxml.dsig.internal.dom.DOMKeyInfoFactory" } System.setProperty("com.sun.security.enableCRLDP", "true") System.setProperty("com.ibm.security.enableCRLDP", "true") System.setProperty("disable_default_context", "true") System.setProperty("ngate_set_jcsp_if_gost", "true") System.setProperty("ru.CryptoPro.key_agreement_validation", "false") val trustStorePath = getBksTrustStore(context) val trustStorePassword = String(BKSTrustStore.STORAGE_PASSWORD) System.setProperty("javax.net.ssl.trustStoreType", BKSTrustStore.STORAGE_TYPE) System.setProperty("javax.net.ssl.trustStore", trustStorePath) System.setProperty("javax.net.ssl.trustStorePassword", trustStorePassword) } fun checkContainersOnDevice() { if (initResult.value!!.errorCode == 0) { val aliases = getAliasesOnStore(HDIMAGE, AlgorithmSelector.DefaultProviderType.pt2012Short) val updatedList = mutableListOf<CertificateDetails>() aliases.forEach { alias -> getCertificateDetails(alias = alias, storeType = HDIMAGE)?.let { cert -> if (userName == cert.subjectFIO) updatedList.add(cert) } } if (updatedList.size == 1 && updatedList[0].validTo.after(Date())) setActiveCertificate(updatedList[0]) certsOnDevice.postValue(updatedList) val activeAlias = sharedPref.getString("ACTIVE_SIGN_SP_$userId", null) if (activeAlias != null) { getCertificateDetails(alias = activeAlias, storeType = HDIMAGE)?.let { cert -> activeCertificate.postValue(cert) } } } } private fun getBksTrustStore(context: Context): String { return context.applicationInfo.dataDir + File.separator + BKSTrustStore.STORAGE_DIRECTORY + File.separator + BKSTrustStore.STORAGE_FILE_TRUST } fun clear() { initCSPProvidersFuture?.cancel(true) }
После того, как провайдер готов к работе, добавляем инициализацию и слушателя на подключение носителя, чтобы мы могли считать и сохранить информацию с Рутокен:
private lateinit var rtTransport: RtTransport private var readerObserver: RtTransport.PcscReaderObserver? = null fun initRutoken() { try { rtTransport = RtPcscBridge.getTransport() rtTransport.initialize(context) // Создаем и добавляем наблюдатель readerObserver = object : RtTransport.PcscReaderObserver { @RequiresApi(Build.VERSION_CODES.O) override fun onReaderAdded(reader: RtTransport.PcscReader) { Timber.d("Reader added: ${reader.name}") readRutoken(reader) } override fun onReaderRemoved(reader: RtTransport.PcscReader) { Timber.d("Reader removed: ${reader.name}") } } rtTransport.addPcscReaderObserver(readerObserver!!) } catch (e: Exception) { Timber.e(e, "initRutoken Error") } } //Функция считывающая все алиасы контейнеров на носителе private fun readRutoken(reader: RtTransport.PcscReader) { val aliases = getAliasesOnStore( reader.name, AlgorithmSelector.DefaultProviderType.pt2012Short ) aliases.forEach { alias -> val certDetailedInfo = getCertificateDetails(alias = alias, storeType = reader.name) if (certDetailedInfo != null) { activeCertificate.postValue(certDetailedInfo) if (userName == certDetailedInfo.subjectFIO) { launch { try { onContainerCopyResult.postValue( createContainerOnDevice( certDetailedInfo.alias, certDetailedInfo.privateKey!!, certDetailedInfo.certificate ) ) } catch (e: Exception) { Timber.e(e, "Ошибка копирования сертификата") onContainerCopyResult.postValue("Ошибка копирования сертификата: ${e.message}") } } } else { onContainerCopyResult.postValue( "Нельзя сохранить сертификат. ФИО пользователя и владельца ЭЦП не совпадают.\n" + "ФИО пользователя: ${inspectorService.cachedInspectorProfile?.user?.inspectorName}\n" + "ФИО владельца ЭЦП: ${certDetailedInfo.subjectFIO}" ) } } } } fun stopRutoken() { readerObserver?.let { rtTransport.removePcscReaderObserver(it) } readerObserver = null }
Здесь мы инициализируем слушатель подключения носителя, а также считываем данные при его подключении с помощью библиотек КрипПро CSP и нескольких утилитарных функций, которые будут приведены ниже. Функция stopRutoken() останавливает мониторинг подключения, так как данный функционал нужен исключительно в одном месте приложения.
object KeyStoreUtil { private const val STR_CMS_OID_SIGNED = "1.2.840.113549.1.7.2"; private const val STR_CMS_OID_DATA = "1.2.840.113549.1.7.1"; private val providerType = AlgorithmSelector.DefaultProviderType.pt2012Short fun getAliasesOnStore( storeType: String, providerType: AlgorithmSelector.DefaultProviderType ): List<String> { val aliasesList = mutableListOf<String>() try { val keyStore = KeyStore.getInstance(storeType, JCSP.PROVIDER_NAME) keyStore.load(null, null) val aliases = keyStore.aliases() while (aliases.hasMoreElements()) { val alias = aliases.nextElement() val cert = keyStore.getCertificate(alias) as? X509Certificate val key = keyStore.getKey(alias, null) if (cert != null) { val keyAlgorithm = cert.publicKey.algorithm if (providerType == AlgorithmSelector.DefaultProviderType.pt2001 && keyAlgorithm.equals(JCP.GOST_EL_DEGREE_NAME, ignoreCase = true) ) aliasesList.add(alias) else if (providerType == AlgorithmSelector.DefaultProviderType.pt2012Short && keyAlgorithm.equals(JCP.GOST_EL_2012_256_NAME, ignoreCase = true) ) aliasesList.add(alias) else if (providerType == AlgorithmSelector.DefaultProviderType.pt2012Long && keyAlgorithm.equals(JCP.GOST_EL_2012_512_NAME, ignoreCase = true) ) aliasesList.add(alias) } } } catch (e: Exception) { Timber.e(e, "getAliasesOnStore Error: ${e.message}") } return aliasesList } // Создает контейнер на устройстве и копирует в него ключи с Рутокена fun createContainerOnDevice(alias: String, privateKey: PrivateKey, certificate: Certificate): String { try { // Пароль для контейнера val password = "".toCharArray() val storeType = HDIMAGE if (checkAliasExists(alias, storeType)) { Timber.e("Container $alias already exists !!! ") return "Контейнер $alias уже был скопирован ранее!" } val keyStore = KeyStore.getInstance(storeType, JCSP.PROVIDER_NAME) keyStore.load(null, null) val entry = JCPPrivateKeyEntry(privateKey, arrayOf(certificate)) val protectedParam = JCPProtectionParameter(password) keyStore.setEntry(alias, entry, protectedParam) if (keyStore.containsAlias(alias)) { Timber.i("Container created successfully with alias: $alias") getAliasesOnStore(storeType, providerType) return "Контейнер $alias успешно скопирован!" } else { Timber.e("Failed to create container with alias: $alias") return "Не удалось скопировать контейнер $alias!\n" + "Пожалуйста, обратитесь к администратору!" } } catch (e: Exception) { Timber.e(e, "Error creating container on device") return "Не удалось скопировать контейнер $alias!\n" + "Ошибка: ${e.message}" } } private fun checkAliasExists(alias: String, storeType: String): Boolean { return getAliasesOnStore(storeType, providerType).contains(alias) } private fun extractInn(input: String): String? { // Регулярное выражение для поиска значения после "1.2.643.3.131.1.1=" val regex = """1\.2\.643\.3\.131\.1\.1=([^,/]+)""".toRegex() val matchResult = regex.find(input) return matchResult?.groups?.get(1)?.value } private fun extractSnils(input: String): String? { // Регулярное выражение для поиска значения после "1.2.643.100.3=" val regex = """1\.2\.643\.100\.3=([^,/]+)""".toRegex() val matchResult = regex.find(input) return matchResult?.groups?.get(1)?.value } private fun extractValue(docType: String, searchIn: String): String? { val regex = when (docType) { "СНИЛС" -> """1\.2\.643\.100\.3=([^,/]+)""".toRegex() "ИНН" -> """1\.2\.643\.3\.131\.1\.1=([^,/]+)""".toRegex() "ОГРН" -> """1\.2\.643\.100\.1=([^,/]+)""".toRegex() "ИННЮЛ" -> """1\.2\.643\.100\.4=([^,/]+)""".toRegex() "EMAILADDRESS" -> """EMAILADDRESS=([^,/]+)""".toRegex() else -> return null } val matchResult = regex.find(searchIn) return matchResult?.groups?.get(1)?.value } private fun getDocPattern(docType: String): Regex { return if (docType == "EMAILADDRESS") """EMAILADDRESS=#16[0-9A-Fa-f]+(?=,|$)""".toRegex() else """$docType=#12[0-9A-Fa-f]+(?=,|$)""".toRegex() } fun getCertificateDetails(alias: String, storeType: String): CertificateDetails? { try { val keyStore = KeyStore.getInstance(storeType, JCSP.PROVIDER_NAME) keyStore.load(null, null) val certificate = keyStore.getCertificate(alias) as? X509Certificate ?: return null val privateKey = keyStore.getKey(alias, null) as? PrivateKey ?: return null val certificateString = certificate.toString().trimIndent() val snils = "СНИЛС=${extractSnils(certificateString)}" val inn = "ИНН=${extractInn(certificateString)}" val subject = certificate.subjectDN.toString() .replace("OID.1.2.643.100.3", "СНИЛС") .replace("OID.1.2.643.3.131.1.1", "ИНН") .replace(getDocPattern("СНИЛС")) { snils } .replace(getDocPattern("ИНН")) { inn } .replace("EMAILADDRESS", "E") val ogrn = "ОГРН=${extractValue("ОГРН", certificateString)}" val innYl = "ИННЮЛ=${extractValue("ИННЮЛ", certificateString)}" val mail = "E=${extractValue("EMAILADDRESS", certificate.issuerDN.toString())}" val issuer = certificate.issuerDN.name.toString() .replace("1.2.643.100.1", "ОГРН") .replace("1.2.643.100.4", "ИННЮЛ") .replace("1.2.840.113549.1.9.1", "EMAILADDRESS") .replace(getDocPattern("ОГРН")) { ogrn } .replace(getDocPattern("ИННЮЛ")) { innYl } .replace(getDocPattern("EMAILADDRESS")) { mail } .replace(",", ", ") .replace(" ", " ") val serialNumber = certificate.serialNumber.toString(16).uppercase().padStart(34, '0') val signatureAlgorithm = certificate.sigAlgName val validFrom = certificate.notBefore val validTo = certificate.notAfter val publicKeyAlgorithm = certificate.publicKey.algorithm return CertificateDetails( alias = alias, certificate = certificate, privateKey = privateKey, issuer = issuer, subject = subject, subjectFIO = extractCommonName(subject), serialNumber = serialNumber, signatureAlgorithm = signatureAlgorithm, validFrom = validFrom, validTo = validTo, publicKeyAlgorithm = publicKeyAlgorithm ) } catch (e: Exception) { Timber.e(e, "@@@ Error extracting certificate details for alias: $alias") } return null } private fun extractCommonName(subject: String): String { val regex = Regex("CN=([^,]+)") val matchResult = regex.find(subject) return matchResult?.groupValues?.get(1) ?: "Неизвестно" } @Throws(java.lang.Exception::class) fun createSign( dataForSign: ByteArray, keys: Array<PrivateKey>, certs: Array<Certificate>, providerType: AlgorithmSelector.DefaultProviderType ): ByteArray { val all = ContentInfo() all.contentType = Asn1ObjectIdentifier( OID(STR_CMS_OID_SIGNED).value ) val cms = SignedData() all.content = cms cms.version = CMSVersion(1) val algorithmSelector = AlgorithmSelector.getInstance(providerType) cms.digestAlgorithms = DigestAlgorithmIdentifiers(1) val a = DigestAlgorithmIdentifier( OID(algorithmSelector.digestAlgorithmOid).value ) a.parameters = Asn1Null() cms.digestAlgorithms.elements[0] = a cms.encapContentInfo = EncapsulatedContentInfo( Asn1ObjectIdentifier( OID(STR_CMS_OID_DATA).value ), null ) // Сертификаты. val nCerts = certs.size cms.certificates = CertificateSet(nCerts) cms.certificates.elements = arrayOfNulls(nCerts) for (i in cms.certificates.elements.indices) { val certificate = ru.CryptoPro.JCP.ASN.PKIX1Explicit88.Certificate() val decodeBuffer = Asn1BerDecodeBuffer(certs[i].encoded) certificate.decode(decodeBuffer) cms.certificates.elements[i] = CertificateChoices() cms.certificates.elements[i].set_certificate(certificate) } val signature = Signature.getInstance( algorithmSelector.signatureAlgorithmName ) var sign: ByteArray? // Подписанты (signerInfos). val nSigners = keys.size cms.signerInfos = SignerInfos(nSigners) for (i in cms.signerInfos.elements.indices) { cms.signerInfos.elements[i] = SignerInfo() cms.signerInfos.elements[i].version = CMSVersion(1) cms.signerInfos.elements[i].sid = SignerIdentifier() val encodedName = (certs[i] as X509Certificate) .issuerX500Principal.encoded val nameBuf = Asn1BerDecodeBuffer(encodedName) val name = Name() name.decode(nameBuf) val num = CertificateSerialNumber( (certs[i] as X509Certificate).serialNumber ) cms.signerInfos.elements[i].sid.set_issuerAndSerialNumber( IssuerAndSerialNumber(name, num) ) cms.signerInfos.elements[i].digestAlgorithm = DigestAlgorithmIdentifier( OID(algorithmSelector.digestAlgorithmOid).value ) cms.signerInfos.elements[i].digestAlgorithm.parameters = Asn1Null() val keyAlgOid = AlgorithmUtility.keyAlgToKeyAlgorithmOid( keys[i].algorithm ) // алгоритм ключа подписи cms.signerInfos.elements[i].signatureAlgorithm = SignatureAlgorithmIdentifier(OID(keyAlgOid).value) cms.signerInfos.elements[i].signatureAlgorithm.parameters = Asn1Null() val data2hash: ByteArray = dataForSign signature.initSign(keys[i]) signature.update(data2hash) sign = signature.sign() cms.signerInfos.elements[i].signature = SignatureValue(sign) } // CMS подпись. val asnBuf = Asn1BerEncodeBuffer() all.encode(asnBuf, true) val sig = asnBuf.msgCopy return sig } }
/** * Служебный класс AlgorithmSelector предназначен * для получения алгоритмов и свойств, соответствующих * заданному провайдеру. */ open class AlgorithmSelector protected constructor( val providerType: DefaultProviderType, val signatureAlgorithmName: String, val digestAlgorithmName: String, val digestAlgorithmOid: String ) { /** * Возможные типы провайдеров. */ enum class DefaultProviderType { pt2001, pt2012Short, pt2012Long } companion object { /** * Получение списка алгоритмов для данного провайдера. * * @param pt Тип провайдера. * @return настройки провайдера. */ fun getInstance(pt: DefaultProviderType): AlgorithmSelector { Timber.d("@@@ getInstance($pt)") return when (pt) { DefaultProviderType.pt2001 -> AlgorithmSelector_2011() DefaultProviderType.pt2012Short -> AlgorithmSelector_2012_256() DefaultProviderType.pt2012Long -> AlgorithmSelector_2012_512() } } /** * Получение типа провайдера по его строковому представлению. * * @param value Тип в виде числа. * @return тип в виде значения из перечисления. */ @JvmStatic fun find(value: Int): DefaultProviderType { Timber.d("@@@ find($value)") return when (value) { 0 -> DefaultProviderType.pt2001 1 -> DefaultProviderType.pt2012Short 2 -> DefaultProviderType.pt2012Long else -> throw IllegalArgumentException("Unknown value") } } } } //------------------------------------------------------------------------------------------------------------------ /** * Класс с алгоритмами ГОСТ 2001. */ private class AlgorithmSelector_2011 : AlgorithmSelector( DefaultProviderType.pt2001, JCP.GOST_EL_SIGN_NAME, JCP.GOST_DIGEST_NAME, JCP.GOST_DIGEST_OID ) /** * Класс с алгоритмами ГОСТ 2012 (256). */ private class AlgorithmSelector_2012_256 : AlgorithmSelector( DefaultProviderType.pt2012Short, JCP.GOST_SIGN_2012_256_NAME, JCP.GOST_DIGEST_2012_256_NAME, JCP.GOST_DIGEST_2012_256_OID ) /** * Класс с алгоритмами ГОСТ 2012 (512). */ private class AlgorithmSelector_2012_512 : AlgorithmSelector( DefaultProviderType.pt2012Long, JCP.GOST_SIGN_2012_512_NAME, JCP.GOST_DIGEST_2012_512_NAME, JCP.GOST_DIGEST_2012_512_OID )
На этом всё, ЭЦП успешно внедрена в мобильное приложение на Android. Надеемся, что данная статья будет полезна всем, кто столкнется с подобной задачей!
Удачи в разработке!
ссылка на оригинал статьи https://habr.com/ru/articles/855314/
Добавить комментарий