Задача - у нас есть розничные продажи и нам надо отправлять информацию о них в госсистему ДМДК.

Как зарегистрироваться в ДМДК и настроить 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)


  1. buldo
    08.09.2024 22:10

    Не сказать, что кейс распространённый, но явно кому-то сэкономит кучу времени.

    P. S. Грустно, что в итоге всё завязывается на дурацкий крипто про


    1. SOProger Автор
      08.09.2024 22:10
      +1

      К КриптоПро претензия в том, что он нам фактически навязан госорганами, и при этом платный. Хотя есть бесплатные аналоги с сертификатом ФСБ, например, ViPNet CSP

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

      Эксперимент по встраивании лицензии в подпись ФНС быстро свернули


      1. buldo
        08.09.2024 22:10

        У меня скорее вопрос к тому, запилили ли крипто про наконец-то поддержку современных версий .NET.


        1. SOProger Автор
          08.09.2024 22:10
          +2

          У них появилась предварительная версия LibCore для dotnet 6+

          Для клиентских рабочих мест продукт не требует приобретения лицензии в отличии от версии КриптоПро.NET для .NET Framework.


  1. mayorovp
    08.09.2024 22:10

    Ну зачем, зачем работать с сырым XML когда есть XmlSerializer!
    Ну и для SOAP существует WCF, хотя конечно же добавлять туда российскую криптографию чёрт ногу сломит...


    1. SOProger Автор
      08.09.2024 22:10

      А прикол в том, что редиски из Гознака нарушают стандарт, можно получать xml из сериализатора, но потом его все равно надо дорабатывать напильником, а иначе не ест


      1. mayorovp
        08.09.2024 22:10

        XmlSerializer способен сам проставить все нужные префиксы, см. XmlSerializerNamespaces


        1. SOProger Автор
          08.09.2024 22:10

          Туда можно и подпись встроить, если постараться. Особенно если есть образец того как оно работает на сыром XML

          Если полезу этот кусок рефакторить, то сделаю как надо.


  1. Ghost_NeverBloom
    08.09.2024 22:10

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