XMLDSig: php + openssl

от автора

Продолжение поста про интеграцию с ГИС ЖКХ — 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 недели жизни. Надеюсь, пригодится вам.

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


ссылка на оригинал статьи https://habr.com/ru/post/710532/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *