Задача - у нас есть розничные продажи и нам надо отправлять информацию о них в госсистему ДМДК.
Как зарегистрироваться в ДМДК и настроить stunnel я напишу отдельную статью, считаем что он есть, настроен и работает. Соответственно, у нас есть ЭЦП, все необходимые сертификаты зарегистрированы.
Далее лезем в документацию сервиса и берем структуру отправляемого xml файла в качестве шаблона, для простоты решения этот шаблон было решено сохранить в виде файлика в папке с программой и туда засовывать необходимые данные.
Решаем передавать каждый чек как отдельное сообщение, благо их в день не много и можно передавать не сразу, а в течение нескольких дней после продажи. Передавать будем в фоне асинхронно, и пытаться передать до тех пор пока ДМДК не съест (иногда она глючит, иногда не работает, иногда на профилактике).
Образец шаблона нашего сообщения о продаже:
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns="urn://xsd.dmdk.goznak.ru/exchange/3.0" xmlns:ns1="urn://xsd.dmdk.goznak.ru/saleoperation/3.0">
<soapenv:Header/>
<soapenv:Body>
<ns:SendBatchSaleRequest>
<ns:CallerSignature>
</ns:CallerSignature>
<ns:RequestData id="data">
<ns:sale>
<ns1:index>1</ns1:index>
<ns1:type>SALE</ns1:type>
<ns1:cheque>
<ns1:fn></ns1:fn>
<ns1:fd>CASH_RECEIPT</ns1:fd>
<ns1:nfd>000</ns1:nfd>
<ns1:date>YYYY-MM-DD</ns1:date>
</ns1:cheque>
</ns:sale>
</ns:RequestData>
</ns:SendBatchSaleRequest>
</soapenv:Body>
</soapenv:Envelope>
Обращу внимание, что надо заполнить поле fn. Туда следует поместить номер фискального накопителя, если касса одна, то его можно сохранить прямо в файл, но не забывать менять его там при замене этого самого фискального накопителя. Правильнее считывать из кассы.
Теперь можно сформировать сообщение для отправки на основе наших данных:
public void SendDmdk()
{
var xdoc = new XmlDocument()
{
PreserveWhitespace = false
};
xdoc.Load("dmdk.xml");
var nfd = xdoc.GetElementsByTagName("ns1:nfd")[0];
nfd.InnerText = bace.receipt.fn.ToString();
xdoc.GetElementsByTagName("ns1:date")[0].InnerText = bace.receipt.date.ToString("yyyy-MM-dd");
var cheque = xdoc.GetElementsByTagName("ns1:cheque")[0];
//Выбираем уины из чека
var uin = (from p in bace.docTable
where !string.IsNullOrWhiteSpace(p.mark)
select p.mark).ToList();
foreach (var m in uin)
{
XmlElement uinList = xdoc.CreateElement("ns1","uinList",cheque.NamespaceURI);
XmlElement xUin = xdoc.CreateElement("ns1","UIN",cheque.NamespaceURI);
xUin.InnerText = m.Trim();
uinList.AppendChild(xUin);
cheque.AppendChild(uinList);
Systems.Dmdk.SetPrefix("ns1", cheque);
}
//Отправляем сообщение
string result = Systems.Dmdk.SendXml(xdoc);
//Помечаем что отправили, если не получилось - оно вываливает эксепшн
bace.receipt.dkdm = 1;
Save();
}
Сообщение сформировали, теперь его надо подписать нашей ЭЦП, добавив подпись в наш XML. Для этого воспользуемся СКЗИ Криптопро с его библиотекой Cryptopro.NET
public static string SendXml(XmlDocument xdoc)
{
//Подпишем документ перед отправкой
X509Certificate2 certificate = GetX509Certificate();
var key = certificate.PrivateKey;
// Создаем объект SignedXml по XML документу.
var signedXml = new PrefixedSignedXml(xdoc, "ds");
signedXml.SigningKey = key;
// Создаем ссылку на node для подписи.
Reference dataReference = new Reference();
dataReference.Uri = "#data";
// Явно проставляем алгоритм хэширования,
// по умолчанию SHA1.
dataReference.DigestMethod = CPSignedXml.XmlDsigGost3411_2012_256Url;
dataReference.AddTransform(new XmlDsigExcC14NTransform());
dataReference.AddTransform(new XmlDsigSmevTransform());
signedXml.SafeCanonicalizationMethods.Add("urn://smev-gov-ru/xmldsig/transform");
// Установка ссылки на узел
signedXml.AddReference(dataReference);
// Создаем объект KeyInfo.
KeyInfo keyInfo = new KeyInfo();
// Добавляем сертификат в KeyInfo
keyInfo.AddClause(new KeyInfoX509Data(certificate));
// Добавляем KeyInfo в SignedXml.
signedXml.KeyInfo = keyInfo;
// Можно явно проставить алгоритм подписи: ГОСТ Р 34.10.
// Если сертификат ключа подписи ГОСТ Р 34.10
// и алгоритм ключа подписи не задан, то будет использован
//XmlDsigGost3410Url
signedXml.SignedInfo.CanonicalizationMethod = SignedXml.XmlDsigExcC14NTransformUrl;
signedXml.SignedInfo.SignatureMethod = CPSignedXml.XmlDsigGost3410_2012_256Url;
// Вычисляем подпись.
signedXml.ComputeSignature();
// Получаем XML представление подписи и сохраняем его
// в отдельном node.
XmlElement xmlDigitalSignature = signedXml.GetXml();
xdoc.GetElementsByTagName("ns:CallerSignature")[0].AppendChild(xdoc.ImportNode(xmlDigitalSignature, true));
string url = "http://127.0.0.1:1501/ws/v3";
System.Net.HttpWebRequest reqPOST = (System.Net.HttpWebRequest)System.Net.WebRequest.Create(url);
reqPOST.Method = "POST";
reqPOST.ContentType = "text/xml; charset=UTF8";
reqPOST.Timeout = 120000;
reqPOST.Accept = "text/xml";
XmlWriter xmlWriter = new XmlTextWriter(reqPOST.GetRequestStream(),
System.Text.Encoding.UTF8);
xdoc.WriteTo(xmlWriter);
xmlWriter.Close();
var response = reqPOST.GetResponse();
string t = "";
using (StreamReader sr = new StreamReader(response.GetResponseStream()))
{
var responseString = sr.ReadToEnd();
t = responseString;
}
response.Close();
return t;
}
Тут используем выбор сертификата:
public static X509Certificate2 GetX509Certificate()
{
// Формуруем коллекцию отображаемых сертификатов.
X509Store store = new X509Store("MY", StoreLocation.CurrentUser);
store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly);
X509Certificate2Collection collection =
(X509Certificate2Collection)store.Certificates;
//Выбираем нужный сертификат из коллекции
foreach (var i in collection)
{
//Выбираем сертификат по шаблону имени, чтобы не прелдагать пользователю выбирать сертифкат каждый раз
if (i.Subject.ToUpper().Contains(sertShablonValue)) return i;
}
// Отображаем окно выбора сертификата.
X509Certificate2Collection scollection =
X509Certificate2UI.SelectFromCollection(collection,
"Выбор секретного ключа по сертификату",
"Выберите сертификат соответствующий Вашему секретному ключу.",
X509SelectionFlag.MultiSelection);
// Проверяем, что выбран сертификат
if (scollection.Count == 0)
{
throw new Exception("Не выбран ни один сертификат.");
}
// Выбран может быть только один сертификат.
return scollection[0];
}
Включаем - не работает :)
Оказывается надо обязательно указывать префиксы иначе ДМДК некорректно разбирает наш XML. В коде выше уже добавлен исправленный класс PrefixedSignedXml, вместо используемого по умолчанию SignedXml, в него сразу встроим вычисление подписи, добавление необходимых префиксов и окончательную упаковку отправляемого файла.
public class PrefixedSignedXml : SignedXml
{
private readonly string _prefix;
public PrefixedSignedXml(XmlDocument document, string prefix)
: base(document)
{
_prefix = prefix;
}
public new void ComputeSignature()
{
BuildDigestedReferences();
var signingKey = SigningKey;
if (signingKey == null)
{
throw new CryptographicException("Cryptography_Xml_LoadKeyFailed");
}
if (SignedInfo.SignatureMethod == null)
{
if (!(signingKey is DSA))
{
if (!(signingKey is RSA))
{
throw new CryptographicException("Cryptography_Xml_CreatedKeyFailed");
}
SignedInfo.SignatureMethod = "http://www.w3.org/2000/09/xmldsig#rsa-sha1";
}
else
{
SignedInfo.SignatureMethod = "http://www.w3.org/2000/09/xmldsig#dsa-sha1";
}
}
if (!(CryptoConfig.CreateFromName(SignedInfo.SignatureMethod) is SignatureDescription description))
{
throw new CryptographicException("Cryptography_Xml_SignatureDescriptionNotCreated");
}
var hash = description.CreateDigest();
if (hash == null)
{
throw new CryptographicException("Cryptography_Xml_CreateHashAlgorithmFailed");
}
GetC14NDigest(hash, _prefix);
m_signature.SignatureValue = description.CreateFormatter(signingKey).CreateSignature(hash);
}
public new XmlElement GetXml()
{
var e = base.GetXml();
SetPrefix(_prefix, e);
return e;
}
//Отражательно вызывать закрытый метод SignedXml.BuildDigestedReferences
private void BuildDigestedReferences()
{
var t = typeof(SignedXml);
var m = t.GetMethod("BuildDigestedReferences", BindingFlags.NonPublic | BindingFlags.Instance);
m?.Invoke(this, new object[] { });
}
private void GetC14NDigest(HashAlgorithm hash, string prefix)
{
var document = new XmlDocument
{
PreserveWhitespace = true
};
var e = SignedInfo.GetXml();
document.AppendChild(document.ImportNode(e, true));
var canonicalizationMethodObject = SignedInfo.CanonicalizationMethodObject;
SetPrefix(prefix, document.DocumentElement); //мы устанавливаем префикс перед вычислением хеша (иначе подпись не будет действительной)
canonicalizationMethodObject.LoadInput(document);
canonicalizationMethodObject.GetDigestedOutput(hash);
}
}
Отправляем, проверяем в личном кабинете - все работает. Надеюсь этот опус поможет сэкономить день на ходьбу по граблям данной системы.
Комментарии (9)
mayorovp
08.09.2024 22:10Ну зачем, зачем работать с сырым XML когда есть XmlSerializer!
Ну и для SOAP существует WCF, хотя конечно же добавлять туда российскую криптографию чёрт ногу сломит...SOProger Автор
08.09.2024 22:10А прикол в том, что редиски из Гознака нарушают стандарт, можно получать xml из сериализатора, но потом его все равно надо дорабатывать напильником, а иначе не ест
mayorovp
08.09.2024 22:10XmlSerializer способен сам проставить все нужные префиксы, см. XmlSerializerNamespaces
SOProger Автор
08.09.2024 22:10Туда можно и подпись встроить, если постараться. Особенно если есть образец того как оно работает на сыром XML
Если полезу этот кусок рефакторить, то сделаю как надо.
Ghost_NeverBloom
08.09.2024 22:10Спасибо за полезный материал. Как раз относительно недавно стал тесно работать с крипто и было очень интересно, как я могу реализовать свой шарпитский навык в текущих условиях. В целом, изучать еще предстоит много...
buldo
Не сказать, что кейс распространённый, но явно кому-то сэкономит кучу времени.
P. S. Грустно, что в итоге всё завязывается на дурацкий крипто про
SOProger Автор
К КриптоПро претензия в том, что он нам фактически навязан госорганами, и при этом платный. Хотя есть бесплатные аналоги с сертификатом ФСБ, например, ViPNet CSP
Получается монополист при декларации свободной конкуренции, да еще за наши деньги.
Эксперимент по встраивании лицензии в подпись ФНС быстро свернули
buldo
У меня скорее вопрос к тому, запилили ли крипто про наконец-то поддержку современных версий .NET.
SOProger Автор
У них появилась предварительная версия LibCore для dotnet 6+
Для клиентских рабочих мест продукт не требует приобретения лицензии в отличии от версии КриптоПро.NET для .NET Framework.