Привет, Хабр! Меня зовут Гоша, я старший инженер-программист в Контуре. Практически любой сценарий ЭДО связан с использованием криптографии, будь то ЭДО с государством или контрагентами: где-то нужно подписать документы, где-то зашифровать архив с отчётом, где-то проверить подпись документа от контрагента. Каждый из таких сценариев хочется тестировать не на реальных данных, но на наиболее похожих в реальности. Помимо самих данных нам нужны сертификаты, имитирующие сертификаты участников ЭДО: организаций, физлиц, государственных органов. Ранее для генерации тестовых сертификатов мы использовали сервис на базе ПАК УЦ, проприетарной штуки, выпускающей сертификаты по определённым правилам, не позволяя издеваться над сроками действия серта как хочется.
Отсюда появилась идея в качестве эксперимента написать небольшой сервис, который мог бы генерировать какие угодно сертификаты с ГОСТ-алгоритмами, но при этом успешно работающие с КриптоПро. В этой статье поделюсь, какая техника скрывается под капотом такой функциональности.

Ключевая сложность возникла не с генерацией серта, атрибутами квалифицированного сертификата и так далее, а с импортом свежесгенерированного сертификата и ЗК в криптопровайдер.
КриптоПро CSP воспринимает несколько разных форматов транспортных ключевых контейнеров:
Проприетарный криптопрошный PFX. Документации по нему нет, нужно заниматься сложным реверс-инжинирингом, есть некоторые статьи про конвертирование стандартного PKCS12 в формат КриптоПро, но обратная операция достаточно затруднительна — всё вдоль и поперек обмазано хитро считающимися контрольными суммами.
PFX по Р 1323565.1.041— 2022. Транспортный ключевой контейнер. Нет реализации на C#, но структура и логика создания описана в стандарте. Кстати, такой контейнер понимает также криптопровайдер VipNet CSP.
Было принято решение реализовывать создание контейнера второго типа. Для генерации сертификата и ключевой пары использована библиотека BouncyCastle, имеющая реализацию на C#, сам сертификат генерируется достаточно просто:
var keyPair = GetKeyPair();
var serialNumber = CreateSerialNumber();
var subject = GetSubjectData(request);
var privateKeyUsagePeriod = CreatePrivateKeyUsagePeriod(request, request.NotBefore);
var attributes = GetExtensions(issuer, keyPair.Public, privateKeyUsagePeriod);
var generator = new X509V3CertificateGenerator();
generator.SetSerialNumber(serialNumber);
generator.SetSubjectDN(subject);
generator.SetIssuerDN(issuer.Certificate.SubjectDN);
generator.SetNotBefore(request.NotBefore.UtcDateTime);
generator.SetNotAfter(request.NotAfter.UtcDateTime);
generator.SetPublicKey(keyPair.Public);
foreach (var attribute in attributes.Values)
generator.AddExtension(attribute.Id, attribute.Critical, attribute.Value);
var factory = new Asn1SignatureFactory(SignatureAlgorithm, issuer.KeyPair.Private, new SecureRandom());
var certificate = generator.Generate(factory);В результате создания ключевой пары в keyPair.Private имеем набор параметров эллиптической кривой (алгоритм ГОСТ Р 34.10-2012 — это один из видов криптографии на эллиптических кривых) и закрытый ключ. Эти данные можно экспортировать в PEM и использовать, например, с OpenSSL, но это не наш случай, нам нужно создать PFX для возможности дальнейшего использования с КриптоПРО CSP.
PFX представляет из себя ASN.1 структуру, содержащую в себе версию стандарта, поле authSafe, которое может содержать зашифрованную информацию о закрытом ключе и сертификате (сертификат может не шифроваться) и поле для контроля целостности с использованием алгоритма HMAC_GOSTR3411_2012_512.

Структура AuthenticatedSafe представляет собой последовательность данных типа ContentInfo: AuthenticatedSafe ::= SEQUENCE OF ContentInfo. Элементы последовательности ContentInfo могут содержать:
Data if unencrypted,
EncryptedData if password-encrypted,
EnvelopedData if public key-encrypted.
В случае представления данных в виде структур EncryptedData или EnvelopedData, поле encryptedContent структуры EncryptedContentInfo представляет собой закодированное значение структуры SafeContents.
Структура SafeContents представляет собой последовательность структур SafeBag: SafeContents ::= SEQUENCE OF SafeBag.
Структура SafeBag может содержать объекты различного типа: ключи, сертификаты ключа проверки электронной подписи. Тип объектов определяется объектным идентификатором.
SafeBag ::= SEQUENCE
{
bagId BAG-TYPE.&id ({PKCS12BagSet})
bagValue [0] EXPLICIT BAG-TYPE.&Type({PKCS12BagSet}{@bagId}),
bagAttributes SET OF PKCS12Attribute OPTIONAL
}При использовании парольной защиты ключ подписи представляется как:
pkcs8ShroudedKeyBag BAG-TYPE ::=
{
PKCS8ShroudedKeyBag IDENTIFIED BY {bagtypes 2}
}Поле bagValue в этом случае содержит ключ подписи и информацию о нём в зашифрованном виде. Представляется в виде структуры EncryptedPrivateKeyInfo. Сертификат ключа проверки электронной подписи независимо от выбранного способа обеспечения конфиденциальности представляется как:
certBag BAG-TYPE ::=
{
CertBag IDENTIFIED BY { bagtypes 3 }
}В случае парольной защиты реализуется механизм PBES2 с использованием гостовских алгоритмов. PBES2 — это механизм, позволяющий на основании пароля получить надёжный криптографический ключ шифрования с использованием ресурсоёмкой функции деривации ключа (PBKDF2), такой механизм затрудняет применение брутфорса.
Первоначально нам надо создать asn.1 структуру, хранящую всю информацию о ЗК и кривой, её и будем шифровать:

В дальнейшем нам нужно получить ключ шифрования для симметричного алгоритма ГОСТ 28147-89 (ГОСТ Р 34.12-2015 Магма):
var keySalt = RandomNumberGenerator.GetBytes(32);
var parametersGenerator = new Pkcs5S2ParametersGenerator(new Gost3411_2012_512Digest());
parametersGenerator.Init(passwordBytes, salt, iterations);
var keyParam = (KeyParameter) parametersGenerator.GenerateDerivedParameters("Gost28147", 256);Для создания ключа нужно криптографически стойким генератором случайных чисел получить соль, в дальнейшем через механизм PBKDF2 происходит деривация ключа на основе пароля:
Вычисляем количество блоков: у нас длина диверсифицированного ключа 256, один блок имеет длину 64: 4 блока.
Для каждого блока вычисляем U1 = HMAC (пароль, соль || номер итерации) где соль || номер итерации int в байтовой форме — байтовая конкатенация байтов соли и значения int.
HMAC вычисляется n раз (параметр сложности), результат каждой итерации подаётся на вход следующей: Un = HMAC (пароль, Un-1).
Фин��льный диверсифицированный ключ получается конкатенацией байтовых строк с последующим усечением до требуемой длины ключа (в нашем случае 256).
Полученный ключ используем для шифрования PrivateKeyInfo в бинарном виде с помощью алгоритма ГОСТ 28147-89 (ГОСТ Р 34.12-2015 Магма):
var keyIv = RandomNumberGenerator.GetBytes(8);
var keyParamWithSBox = new ParametersWithSBox(keyParam, TC26_CIPHER_Z);
var keyParamWithIv = new ParametersWithIV(keyParamWithSBox, iv);
var cipher = new BufferedBlockCipher(new CfbBlockCipher(new Gost28147Engine(), 64));
cipher.Init(true, keyParamWithIv);
var encryptedData = cipher.DoFinal(data);После этого запаковываем зашифрованные данные в портфель ключей, обогащаем id ключа и именем контейнера, благодаря id ключа можно сопоставить его с сертом, а имя контейнера подтянется в КриптоПро при установке сертификата на тачку:
var pbes2Params = CreatePbes2Params(keySalt, keyIv, HmacIterations);
var encAlgId = new AlgorithmIdentifier(
PkcsObjectIdentifiers.IdPbeS2,
pbes2Params);
var encryptedPrivateKeyInfo = new EncryptedPrivateKeyInfo(encAlgId, encryptedData);
var kName = new Asn1EncodableVector
{
new DerSequence(
PkcsObjectIdentifiers.Pkcs9AtLocalKeyID,
new DerSet(X509ExtensionUtilities.CreateSubjectKeyIdentifier(cert.GetPublicKey()))),
new DerSequence(
PkcsObjectIdentifiers.Pkcs9AtFriendlyName,
new DerSet(new DerBmpString($"easycert-serial-{cert.SerialNumber.ToString(16)}")))
};
var keyBag = new SafeBag(PkcsObjectIdentifiers.Pkcs8ShroudedKeyBag, encryptedPrivateKeyInfo.ToAsn1Object(), DerSet.FromVector(kName));
var bagVector = new Asn1EncodableVector { keyBag };
Asn1Sequence safeContents = new DerSequence(bagVector);
var keyContentInfo = new ContentInfo(
PkcsObjectIdentifiers.Data,
new DerOctetString(safeContents.GetEncoded(Asn1Encoding)));Таким образом сформировано поле зашифрованных данных структуры authSafe.
Теперь нужно сформировать поле незашифрованных данных, в которых содержится сертификат, к которому подходит ЗК:
var certBags = new Asn1EncodableVector(1);
var cName = new Asn1EncodableVector{
{
new DerSequence(
PkcsObjectIdentifiers.Pkcs9AtLocalKeyID,
new DerSet(X509ExtensionUtilities.CreateSubjectKeyIdentifier(cert.GetPublicKey())))
};
Asn1Encodable certValue = new DerOctetString(cert.GetEncoded());
Asn1Encodable certBagSeq = new DerSequence(
PkcsObjectIdentifiers.X509Certificate,
new DerTaggedObject(true, 0, certValue));
certBags.Add(
new SafeBag(PkcsObjectIdentifiers.CertBag, certBagSeq, DerSet.FromVector(cName)));
var certsBagEncoded = new DerSequence(certBags).GetEncoded(Asn1Encoding);Объединяя полученные данные формируем структуру authSafe:
var authenticatedSafeVector = new Asn1EncodableVector{
certContentInfo,
keyContentInfo
};
var encodedAuthSafe = new DerSequence(authenticatedSafeVector).GetEncoded(Asn1Encoding);Целостность ТКК обеспечивается с использованием алгоритма HMAC_GOSTR3411_2012_512. Ключ для этого алгоритма также вырабатывается по алгоритму PBKDF2 в соответствии с теми же значениями пароля P и параметра c, что и при шифровании, и новой случайной солью длиной от 8 до 32 байт. Параметр dkLen для алгоритма PBKDF2 устанавливается равным 96 байт. Ключом алгоритма HMAC_GOSTR3411_2012_512 должны быть младшие 32 байта 96-байтовой последовательности, выработанной с помощью алгоритма PBKDF2. Функция HMAC_GOSTR3411_2012_512 вычисляется от содержимого поля content поля структуры authSafe.
При работе с контейнером криптопровайдер вычисляет имитовставку на основе данных контейнера и сравнивает с сохранённым при формировании контейнера значением, в случае совпадения целостность контейнера подтверждается.
MacData ::= SEQUENCE
{
mac DigestInfo,
macSalt OCTET STRING,
iterations INTEGER DEFAULT 1 -- Note: The default is for historical reasons and its -- use is deprecated.
}
DigestInfo ::= SEQUENCE
{
digestAlgorithm DigestAlgorithmIdentifier,
digest Digest
}
DigestAlgorithmIdentifier ::= AlgorithmIdentifier
Digest ::= OCTET STRING
AlgorithmIdentifier ::= SEQUENCE
{
algorithm OBJECT IDENTIFIER
parameters ANY DEFINED BY algorithm OPTIONAL
}Внутри этой структуры указан алгоритм формирования MAC, 32 байтная соль и количество итераций хэширования (в случае ГОСТа используем HMAC на основе ГОСТ Р 34.11-2012).

var macSalt = RandomNumberGenerator.GetBytes(32);
// (g.grantsev): MAC: PBKDF2 (96 байт) + последние 32 байта как HMAC ключ!
var pbkdf2MacKey = Pbkdf2GostDeriveKey(passwordBytes, macSalt, HmacIterations, 96);
var hmacKey = CreateHmacKey(pbkdf2MacKey);
var macBytes = CalculateHmac(hmacKey, encodedAuthSafe);
private static byte[] Pbkdf2GostDeriveKey(byte[] password, byte[] salt, int iterations, int dkLen)
{
var gen = new Pkcs5S2ParametersGenerator(new Gost3411_2012_512Digest());
gen.Init(password, salt, iterations);
var keyParam = (KeyParameter) gen.GenerateDerivedMacParameters(dkLen * 8);
return keyParam.GetKey();
}
private static byte[] CreateHmacKey(byte[] pbkdf2MacKey)
{
var hmacKey = new byte[32];
Array.Copy(pbkdf2MacKey, pbkdf2MacKey.Length - 32, hmacKey, 0, 32);
return hmacKey;
}
private static byte[] CalculateHmac(byte[] hmacKey, byte[] encodedAuthSafe)
{
var hmac = new HMac(new Gost3411_2012_512Digest());
hmac.Init(new KeyParameter(hmacKey));
hmac.BlockUpdate(encodedAuthSafe, 0, encodedAuthSafe.Length);
var macBytes = new byte[hmac.GetMacSize()];
hmac.DoFinal(macBytes, 0);
return macBytes;
}Далее упаковываем в структуру MacData:
var macData = CreateMacData(HmacIterations, macBytes, macSalt);
private static MacData CreateMacData(int iterations, byte[] macBytes, byte[] macSalt)
{
var digestInfo = new DigestInfo(
new AlgorithmIdentifier(
RosstandartObjectIdentifiers.id_tc26_gost_3411_12_512,
DerNull.Instance),
macBytes);
var macData = new MacData(
digestInfo,
new DerOctetString(macSalt),
new DerInteger(iterations));
return macData;
}Объединяя полученные структуры, крафтим PFX:
var authSafeContentInfo = new ContentInfo(
PkcsObjectIdentifiers.Data,
new DerOctetString(encodedAuthSafe));
return new Pfx(authSafeContentInfo, macData);Таким образом формируется PFX по ГОСТу, далее уже можно использовать по своему усмотрению. В целом, всё это звучит достаточно понятно: возьми ГОСТ и сделай как написано, но мы умолчим, сколько бессонных отпускных ночей ушло на чтение ГОСТов, чтобы сделать контейнер понятным для КриптоПро :)
Описанная выше логика была оформлена в виде сервиса, дополнилась новым функционалом, в итоге у нас получился собственный небольшой УЦ, который занимается не только генерацией случайных сертификатов, но и позволяет:
Обновить уже существующий сертификат любого УЦ с копированием данных из старого сертификата.
Подписать произвольный запрос на сертификат.
Генерировать сертификаты различной структуры: КЭП ФЛ, ЮЛ, ИП, Гос. органов, НЭП.
Выпускать сертификаты из-под разных корней.
Отзывать сертификаты в будущем, прошлом, настоящем с авторизацией на выпустившего его пользователя/приложение.
Отменять отзыв.
Проверить отзыв через CRL и OCSP.


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