Продолжение поста про интеграцию с ГИС ЖКХ - https://habr.com/en/post/710462/

В этой части разберём как правильно подписать xml-запрос в php при помощи openssl

В этой статье я не разбираю почему xmldsig формируется именно так - я привожу пример реализации. Поэтому я ожидаю, что вы уже знакомы с основными понятиями и алгоритмом подписания по xmldsig.

Будем использовать модифицированную версию openssl из первого поста, поэтому он обязателен к прочтению

В основе всего лежит базовый класс Xml, наследуемый от DOMDocument:

Xml
<?php

namespace gis\xml;

use DOMDocument as GlobalDOMDocument;
use DOMElement;
use RuntimeException;

class Xml extends GlobalDOMDocument
{
    public function setVersion(string $version)
    {
        $this->addAttributeToTag($this->getRequestPayloadXml()->localName, new TagAttributeData([
            'attributeName' => 'base:version',
            'attributeValue' => $version,
            'attributeNamespace' => 'xmlns:base',
            'attributeNamespaceValue' => 'http://dom.gosuslugi.ru/schema/integration/base/'
        ]));
    }

    public function addAttributeToTag(string $tagName, TagAttributeData $tagAttributeData)
    {
        /** @var DOMElement $tag */
        $tag = $this->getElementByTagName($tagName);

        if ($tagAttributeData->getAttributeNamespace()) {
            $tag->setAttribute($tagAttributeData->getAttributeNamespace(), $tagAttributeData->getAttributeNamespaceValue());
        }
        $tag->setAttribute($tagAttributeData->getAttributeName(), $tagAttributeData->getAttributeValue());

        $tmpDoc = self::fromText($tag->C14N(true), false);
        $imported = $this->importNode($tmpDoc->documentElement, true);
        $body = $this->getBody();
        $body->removeChild($tag);
        $body->appendChild($imported);
    }

    public function getRequestPayloadXml(): DOMElement
    {
        $availableTags = ['exportDSRsRequest', 'importDSRResponsesRequest'];
        foreach ($availableTags as $tagName) {
            if ($found = $this->getElementByTagName($tagName)) {
                return $found;
            }
        }
        throw new RuntimeException('Not yet implemented');
    }

    public function getElementByTagName(string $qualifiedName): ?DOMElement
    {
        return $this->getElementsByTagName($qualifiedName)->item(0);
    }

    public static function fromText(string $source, bool $canonicalize = true): static
    {
        $xml = new static('1.0', 'utf-8');
        $xml->loadXML($source);

        return $canonicalize ? self::fromText($xml->C14N(), false) : $xml;
    }

    public function getBody(): DOMElement
    {
        return $this->getElementByTagName('Body');
    }
}

В нём используется класс TagAttributeData - это просто DTO с данными по атрибуту тэга:

TagAttributeData
<?php

namespace gis\xml;

use yii\base\Component;

final class TagAttributeData extends Component
{
    public string $attributeName;
    public string $attributeValue;
    public ?string $attributeNamespace = null;
    public ?string $attributeNamespaceValue = null;

    public function getAttributeName(): string
    {
        return $this->attributeName;
    }

    public function getAttributeValue(): string
    {
        return $this->attributeValue;
    }

    public function getAttributeNamespace(): ?string
    {
        return $this->attributeNamespace;
    }

    public function getAttributeNamespaceValue(): ?string
    {
        return $this->attributeNamespaceValue;
    }
}

От него наследует класс SignedXml, который собственно и подписывает запрос:

SignedXml
<?php

namespace gis\xml;

use common\helpers\DateHelper;
use DOMElement;
use DOMNode;
use gis\components\UUID;
use gis\openssl\OpenSSLInterface;
use Yii;

final class SignedXml extends Xml
{
    private OpenSSLInterface $openssl;

    public function __construct(string $version, string $encoding)
    {
        $this->openssl = Yii::$app->get('openssl');

        parent::__construct($version, $encoding);
    }

    public function saveXML(?DOMNode $node = null, int $options = null): string|false
    {
        $this->tagSignedElement();

        $signatureElement = $this->importSignatureContainer();
        $this->digestSignedProperties($signatureElement);
        $this->digestSignedInfo($signatureElement);

        return parent::saveXML();
    }

    private function tagSignedElement(): void
    {
        $this->addAttributeToTag($this->getRequestPayloadXml()->localName, new TagAttributeData([
            'attributeName' => 'Id',
            'attributeValue' => 'signed-data-container',
        ]));
    }

    private function importSignatureContainer(): DOMElement
    {
        $x509 = $this->openssl->getX509();

        $signedInfoProperties = [
            'signatureId' => UUID::new(),
            'keyInfoId' => UUID::new(),
            'canonicalDataDigest' => $this->openssl->digest($this->getRequestPayloadXml()->C14N(true)),
            'x509CertDigest' => $this->openssl->digest(base64_decode($x509->getStripped())),
            'x509Cert' => $x509->getStripped(),
            'signingTime' => DateHelper::soap(),
            'x509IssuerName' => $x509->getIssuerName(),
            'x509SerialNumber' => $x509->getSerialNumber()
        ];

        $render = Yii::$app->getView()->renderPhpFile(Yii::getAlias('@gis/templates/full-signature.php'), $signedInfoProperties);
        $signatureContainer = Xml::fromText($render);
        $signatureElement = $this->importNode($signatureContainer->documentElement, true);
        $actualRequestBody = $this->getRequestPayloadXml();
        $firstParam = $actualRequestBody->childNodes->item(0);
        $actualRequestBody->insertBefore($signatureElement, $firstParam);

        return $signatureElement;
    }

    private function digestSignedProperties(DOMElement $signatureElement): void
    {
        $signedPropertiesElement = $signatureElement->getElementsByTagName('SignedProperties')->item(0);
        $signedPropertiesDigest = $this->openssl->digest($signedPropertiesElement->C14N(true));
        $signatureElement->getElementsByTagName('DigestValue')->item(1)->textContent = $signedPropertiesDigest;
    }

    private function digestSignedInfo(DOMElement $signatureElement): void
    {
        $signatureValue = $this->openssl->sign($signatureElement->getElementsByTagName('SignedInfo')->item(0)->C14N(true));
        $signatureElement->getElementsByTagName('SignatureValue')->item(0)->textContent = $signatureValue;
    }
}

В нём используется класс UUID:

UUID
<?php

namespace gis\components;

class UUID
{
    public static function new(): string
    {
        $data = random_bytes(16);

        return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
    }
}

Для подписи используются два интерфейса OpenSSLInterface и X509Interface, приведу их текущие реализации:

OpenSSLInterface
<?php

namespace gis\openssl;

use gis\components\TemporaryFile;
use Yii;
use yii\base\Component;

class OpenSSL extends Component implements OpenSSLInterface
{
    private X509Interface $x509;

    public function __construct($config = [])
    {
        $this->x509 = Yii::$app->get('x509')::fromFile(Yii::$app->params['gis']['openssl']['x509.pem']);

        parent::__construct($config);
    }

    public function digest(string $value): string
    {
        return $this->dgst($value);
    }

    private function dgst(string $value, ?string $privateKeyPath = null): bool|string|null
    {
        $temporaryFile = new TemporaryFile($value);
        $filepath = $temporaryFile->getFilepath();

        $command = [
            'cat',
            $filepath,
            '|',
            'openssl',
            'dgst',
            '-md_gost12_256',
            '-binary',
        ];

        if ($privateKeyPath) {
            $command = array_merge($command, [
                '-sign',
                $privateKeyPath
            ]);
        }

        $command = array_merge($command, [
            '|',
            'base64',
            '-w',
            '0'
        ]);

        return shell_exec(implode(' ', $command));
    }

    public function sign(string $value): string
    {
        return $this->dgst($value, Yii::$app->params['gis']['openssl']['private.key']);
    }

    public function getX509(): X509Interface
    {
        return $this->x509;
    }
}

В нём используется класс TemporaryFile:

TemporaryFile
<?php

namespace gis\components;

class TemporaryFile
{
    private string $filepath;

    public function __construct(?string $data = null)
    {
        $this->filepath = tempnam(sys_get_temp_dir(), 'php');
        $data && $this->setData($data);
    }

    public function setData(string $data): void
    {
        file_put_contents($this->getFilepath(), $data);
    }

    public function getFilepath(): bool|string
    {
        return $this->filepath;
    }

    public function __destruct()
    {
        $this->destroy();
    }

    private function destroy(): void
    {
        if (!file_exists($this->filepath)) {
            return;
        }
        unlink($this->filepath);
    }
}

X509Interface
<?php

namespace gis\openssl;

use common\components\MathHelper;
use yii\base\Component;

final class X509 extends Component implements X509Interface
{
    private string $content;

    public static function fromFile(string $filepath): static
    {
        $x509 = new self();
        $x509->content = file_get_contents($filepath);

        return $x509;
    }

    public function getSerialNumber(): string
    {
        $serialNumber = $this->getParsedValue('serialNumber');

        return str_starts_with($serialNumber, '0x')
            ? MathHelper::bcHexToDecimal(substr($serialNumber, 2))
            : $serialNumber;
    }

    private function getParsedValue(string $key): mixed
    {
        $read = openssl_x509_read($this->content);

        return openssl_x509_parse($read)[$key] ?? null;
    }

    public function getIssuerName(): string
    {
        $issuer = $this->getParsedValue('issuer');
        $issuerData = [
            'CN' => $issuer['CN'],
            'O' => $issuer['O'],
            'OU' => $issuer['OU'],
            'C' => $issuer['C'],
            'ST' => $issuer['ST'],
            'L' => $issuer['L'],
            'E' => $issuer['emailAddress'],
            'STREET' => $issuer['street'],
            '1.2.643.100.4' => $issuer['INN'] ?? $issuer['UNDEF'] ?? null,
            '1.2.643.100.1' => $issuer['OGRN'],
        ];
        $mergedIssuerData = [];
        foreach ($issuerData as $key => $value) {
            $value = str_replace(['"', ','], ['\"', '\,'], $value);
            $mergedIssuerData[] = "$key=$value";
        }

        return implode(',', $mergedIssuerData);
    }

    public function getStripped(): string
    {
        $allLines = explode(PHP_EOL, $this->content);
        unset($allLines[0], $allLines[count($allLines) - 1]);

        return implode(PHP_EOL, $allLines);
    }
}

В них используется шаблон подписи:

Разметка шаблона подписи
<?php

/**
 * @var string $signatureId
 * @var string $keyInfoId
 * @var string $canonicalDataDigest - digest1
 * @var string $x509CertDigest - digest2
 * @var string $x509Cert
 * @var string $signingTime
 * @var string $x509IssuerName
 * @var string $x509SerialNumber
 */

?>
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Id="xmldsig-<?= $signatureId ?>">
    <ds:SignedInfo>
        <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
        <ds:SignatureMethod Algorithm="urn:ietf:params:xml:ns:cpxmlsec:algorithms:gostr34102012-gostr34112012-256" />
        <ds:Reference URI="#signed-data-container">
            <ds:Transforms>
                <ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" />
                <ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
            </ds:Transforms>
            <ds:DigestMethod Algorithm="urn:ietf:params:xml:ns:cpxmlsec:algorithms:gostr34112012-256" />
            <ds:DigestValue><?= $canonicalDataDigest ?></ds:DigestValue>
        </ds:Reference>
        <ds:Reference URI="#xmldsig-<?= $signatureId ?>-signedprops" Type="http://uri.etsi.org/01903#SignedProperties">
            <ds:Transforms>
                <ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
            </ds:Transforms>
            <ds:DigestMethod Algorithm="urn:ietf:params:xml:ns:cpxmlsec:algorithms:gostr34112012-256" />
            <ds:DigestValue></ds:DigestValue>
        </ds:Reference>
    </ds:SignedInfo>
    <ds:SignatureValue></ds:SignatureValue>
    <ds:KeyInfo Id="xmldsig-<?= $keyInfoId ?>">
        <ds:X509Data xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
            <ds:X509Certificate><?= $x509Cert ?></ds:X509Certificate>
        </ds:X509Data>
    </ds:KeyInfo>
    <ds:Object>
        <xades:QualifyingProperties xmlns:xades="http://uri.etsi.org/01903/v1.3.2#" Target="#xmldsig-<?= $signatureId ?>">
            <xades:SignedProperties Id="xmldsig-<?= $signatureId ?>-signedprops">
                <xades:SignedSignatureProperties>
                    <xades:SigningTime><?= $signingTime ?></xades:SigningTime>
                    <xades:SigningCertificate>
                        <xades:Cert>
                            <xades:CertDigest>
                                <ds:DigestMethod Algorithm="urn:ietf:params:xml:ns:cpxmlsec:algorithms:gostr34112012-256" />
                                <ds:DigestValue><?= $x509CertDigest ?></ds:DigestValue>
                            </xades:CertDigest>
                            <xades:IssuerSerial>
                                <ds:X509IssuerName><?= $x509IssuerName ?></ds:X509IssuerName>
                                <ds:X509SerialNumber><?= $x509SerialNumber ?></ds:X509SerialNumber>
                            </xades:IssuerSerial>
                        </xades:Cert>
                    </xades:SigningCertificate>
                </xades:SignedSignatureProperties>
            </xades:SignedProperties>
        </xades:QualifyingProperties>
    </ds:Object>
</ds:Signature>

Теперь посмотрим как это всё слить воедино, чтобы получить подписанный xml-запрос

На этом этапе я предположу, что вы уже знаете как из WSDL сформировать xml-запрос

Если нет
  1. гуглим "php soap client wsdl"

  2. пишем кастомную обёртку над soap client

  3. перехватываем сгенерированный из wsdl запрос

Пример кастомной обёртки можно найти в первой части статей по ссылке вверху

$xml = Xml::fromText($request); # $request = ваш перехваченный wsdl-запрос
$xml->setVersion('13.1.1.6');

$signedXml = SignedXml::fromText($xml->saveXML())->saveXML();

Примечания:

  1. На строке 38 в классе XML идёт перечисление поддерживаемых wsdl-запросов, потому что по-другому вычленять тело запроса сложновато

  2. По классу SignedXML:

    1. Реализацию интерфейса OpenSSLInterface инжектите как хотите, конкретно в Yii2 это проще через service locator было сделать

    2. На строке 52 есть DateHelper::soap() - это просто текущая дата в формате Y-m-d\TH:i:s.uP

    3. На строке 57 идёт импорт шаблона подписи - меняйте на нужный вам. Моя реализация тут опять же напрямую зависит от фреймворка

  3. По классу OpenSSL:

    1. Реализацию интерфейса X509Interface инжектите как хотите)

    2. Да, для формирования digest действительно используется shell_exec. Если очень хочется, то пересобирайте php с поддержкой openssl с поддержкой `gost-engine`)

    3. На строке 59 в dgst вторым аргументом передаётся секретный ключ вашего сертификита, который мы получили в первой статье

  4. По классу X509:

    1. Пожалуй, то, от чего больше всего седеют волосы во всей этой интеграции - это названия полей с данными. У всех нормальных людей ИНН лежит в поле INN, у гис жкх это почему-то 1.2.643.100.4

    2. На строке 25 мы получаем серийник сертификата:

      public static function bcHexToDecimal(string $hex): string
      {
          if (strlen($hex) === 1) {
              return hexdec($hex);
          }
      
          $remain = substr($hex, 0, -1);
          $last = substr($hex, -1);
      
          return bcadd(bcmul(16, self::bcHexToDecimal($remain)), hexdec($last));
      }

Пара-пара-пам! Вот и всё. Этот манёвр стоил мне 2 недели жизни. Надеюсь, пригодится вам.

Как всегда - рад любым вопросам, всё что надо - допишу в статью.

Комментарии (0)