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