Продолжение поста про интеграцию с ГИС ЖКХ — 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-запрос
Если нет
-
гуглим «php soap client wsdl»
-
пишем кастомную обёртку над soap client
-
перехватываем сгенерированный из wsdl запрос
Пример кастомной обёртки можно найти в первой части статей по ссылке вверху
$xml = Xml::fromText($request); # $request = ваш перехваченный wsdl-запрос $xml->setVersion('13.1.1.6'); $signedXml = SignedXml::fromText($xml->saveXML())->saveXML();
Примечания:
-
На строке 38 в классе
XMLидёт перечисление поддерживаемых wsdl-запросов, потому что по-другому вычленять тело запроса сложновато -
По классу
SignedXML:-
Реализацию интерфейса
OpenSSLInterfaceинжектите как хотите, конкретно вYii2это проще через service locator было сделать -
На строке 52 есть
DateHelper::soap()— это просто текущая дата в форматеY-m-d\TH:i:s.uP -
На строке 57 идёт импорт шаблона подписи — меняйте на нужный вам. Моя реализация тут опять же напрямую зависит от фреймворка
-
-
По классу
OpenSSL:-
Реализацию интерфейса
X509Interfaceинжектите как хотите) -
Да, для формирования
digestдействительно используетсяshell_exec. Если очень хочется, то пересобирайтеphpс поддержкойopensslс поддержкой `gost-engine`) -
На строке 59 в
dgstвторым аргументом передаётся секретный ключ вашего сертификита, который мы получили в первой статье
-
-
По классу
X509:-
Пожалуй, то, от чего больше всего седеют волосы во всей этой интеграции — это названия полей с данными. У всех нормальных людей ИНН лежит в поле INN, у гис жкх это почему-то
1.2.643.100.4 -
На строке 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/
Добавить комментарий