Интеграция с ЕСИА v2 на Debian 11 + php 7

Передо мной была поставлена задача «чтобы посетители могли войти на сайт через Госуслуги». Задачка не новая, давно решена. На PHP для этого используют библиотеки, но есть пара оговорок.

Проблема 1

С января 2020 для интеграции требуется использовать только ГОСТ-шифрование. Варианты решения:

  • дергать внешнюю утилиту, которая умеет ГОСТ

  • собрать php с поддержкой ГОСТ

Оба варианта требуют применения лицензированного криптографического средства (КриптоПро), в рамках статьи мы пойдем по второму пути. Весь php можно не пересобирать, ведь есть механизм плагинов. Поэтому достаточно собрать плагин libphpcades от КриптоПро.

Для Debian 11 и php 7.4 инструкцию от КриптоПро мне пришлось чуть доработать напильником. Шаги 2 и 6 пропускаем, достаточно лишь установить пакет php-dev. После этого на шаге 7 в Makefile.unix можно задать путь к заголовочным файлам из пакета php-dev

PHPDIR=/usr/include/php/20190902

Далее на шаге 8 скачиваем патч для php7 и применяем его к исходным файлам libphpcades:

cd /opt/cprocsp/src/phpcades/
patch -p0 < /path/to/php7_support.patch

Шаг »8 и ¾ »: Чтобы библиотека собралась под debian 11, добавляем к параметрам компилятора CFLAGS флаг

-fpermissive

А из параметров сборки LDFLAGS удаляем флаг

-lcplib

И после этого библиотека успешно собирается. По-хорошему, теперь собранную libphpcades.so надо бы оформить в пакет deb…

5d31b5bf01c2ff4e6f8bc9f301e6c402.jpg

Так что на шаге 11 вместо создания симлинка мы просто копируем собранную библиотеку в компанию к остальным модулям php

cp /opt/cprocsp/src/phpcades/libphpcades.so /usr/lib/php/20190902/

После чего исходные файлы нам уже не нужны, их можно удалить.

Для тестового контура ЕСИА ГОСТ-сертификат можно сгенерировать самостоятельно, для промышленного — придется получить в уполномоченном Удостоверяющем центре (ОГРН в сертификате должен совпадать с ОГРН вашей ИС в промышленном контуре ЕСИА)

Любым способом переносим ключ и сертификат нашей ИС в /var/opt/cprocsp/keys/www-data/MYSITE.000, можно простым копированием, можно командой

sudo -u www-data /opt/cprocsp/bin/amd64/csptest -keycopy -contsrc '\\.\FLASH\ivanoff' -contdest '\\.\HDIMAGE\MYSITE'

Далее устанавливаем сертификат и его закрытый ключ в хранилище My пользователя www-data:

sudo -u www-data /opt/cprocsp/bin/amd64/certmgr -inst -store uMy -cont '\\.\HDIMAGE\MYSITE'

В процессе установки проверяем, что есть связь сертификата с приватным ключом в контейнере:

PrivateKey Link : Yes
Container : \\.\HDIMAGE\MYSITE

Устанавливаем открытый сертификат ЕСИА в контейнер Users пользователя www-data

sudo -u www-data /opt/cprocsp/bin/amd64/certmgr -install -store uUsers -file ./TESIA\ GOST\ 2012.cer
sudo -u www-data /opt/cprocsp/bin/amd64/certmgr -install -store uUsers -file ./GOST\ 2012\ PROD.cer

Проблема 2

С версии 2.90 Методических рекомендаций используемый endpoint /aas/oauth2/ac объявлен устаревшим и не рекомендован к использованию. Предложено переходить на /aas/oauth2/v2/ac, а реализации в указанных библиотеках нет.

Вот этим‑то мы сейчас и займемся. Снова достаем напильник и применяем его ну например к библиотечке https://github.com/fr05t1k/esia

Добавляем новые свойства конфигурации:

class Config 
{ 
 private $tokenUrlPath_V3 = 'aas/oauth2/v3/te'; 
 private $codeUrlPath_V2 = 'aas/oauth2/v2/ac'; 
 protected $ESIACertSHA1 = '01e6041097ccf5a26da1d75fdbb1e7aaee07bd2a'; // SHA1 хэш сертификата ЕСИА (openssl sha1 ./GOST\ 2012\ PROD.cer)

 public function getTokenUrl_V3(): string 
{
    return $this->portalUrl . $this->tokenUrlPath_V3; 
}

На основе метода buildUrl() создаем метод buildUrl_V2(), который будет работать с endpoint из $codeUrlPath_V2

В endpoint «v2/ac» поменялся состав и порядок конкатенации параметров для составления client_secret (теперь это ClientID + Scope + Timestamp +State + RedirectURI) и добавился client_certificate_hash.

class OpenId {
protected $clientCertHash = null;

public function buildUrl_V2()
{
     $timestamp = $this->getTimeStamp();
     $state = $this->buildState();
     // собираем client_secret по новым правилам
     $message =  $this->config->getClientId()
          . $this->config->getScopeString()
          . $timestamp         
          . $state         
          . $this->config->getRedirectUrl();    
     // используем алгоритм ГОСТ2012 для подписания     
     $this->signer = new SignerCPDataHash(
          $config->getCertPath(),           // для КриптоПро эти 
          $config->getPrivateKeyPath(),     // параметры не нужны
          $config->getPrivateKeyPassword(), // потому что сертификат и ключ
          $config->getTmpPath()             // импортированы в хранилище
     );
     $clientSecret = $this->signer->sign($message);
     $url = $this->config->getCodeUrl_V2() . '?%s';
     $params = [
         'client_id' => $this->config->getClientId(),
         'client_secret' => $clientSecret,
         'redirect_uri' => $this->config->getRedirectUrl(),
         'scope' => $this->config->getScopeString(),
         'response_type' => $this->config->getResponseType(),
         'state' => $state,
         'access_type' => $this->config->getAccessType(),
         'timestamp' => $timestamp,
         'client_certificate_hash' => $this->clientCertHash, // ГОСТ-хэш нашего сертификата     
     ];
     $request = http_build_query($params);      
     return sprintf($url, $request); 
}

Да, client_certificate_hash можно вычислить предлагаемой в Методических рекомендациях утилитой и вписать как еще один параметр конфигурации. Возможно, так даже правильно. Но почему бы не научиться вычислять его самостоятельно?

Итак, создаем класс SignerCPDataHash, который использует libphpcades для вычисления ГОСТ хэша сертификата ИС (параметр client_certificate_hash) и подписания client_secret по алгоритму data hash.

Для этого используется сертификат с CN=ClientID из пользовательского хранилища My установленного КриптоПро.

class SignerCPDataHash extends AbstractSignerPKCS7 implements SignerInterface
{
    public function sign(string $message): string
    {
        $store = new \CPStore();
        $store->Open(CURRENT_USER_STORE, 'My', STORE_OPEN_READ_ONLY); // используем хранилище My текущего пользователя (www-data)
        $certs = $store->get_Certificates();
        $certlist = $certs->Find(CERTIFICATE_FIND_SUBJECT_NAME, $this->config->getClientId(), 0); // ищем сертификат, у которогое Subject = мнемонике нашей ИС
        $cert = $certlist->Item(1);
        if (!$cert) {
            throw new CannotReadCertificateException('Cannot read the certificate');
        }        
        // у сертификата должна быть связка с закрытым ключом
        if (false===$cert->HasPrivateKey()) {
            throw new CannotReadPrivateKeyException('Cannot read the private key');
        }        

        $pk=$cert->PublicKey();
        $oid=$pk->get_Algorithm();                  
        $hd = new \CPHashedData();    
        switch ($oid->get_Value()) { // https://cpdn.cryptopro.ru/content/csp40/html/group___pro_c_s_p_ex_DP8.html
            case '1.2.643.7.1.1.1.1':
                $hd->set_Algorithm(CADESCOM_HASH_ALGORITHM_CP_GOST_3411_2012_256);
                break;
            case '1.2.643.7.1.1.1.2':
                $hd->set_Algorithm(CADESCOM_HASH_ALGORITHM_CP_GOST_3411_2012_512);
                break;
        }
        $hd->set_DataEncoding(BASE64_TO_BINARY);
        $hd->Hash($cert->Export(ENCODE_BASE64));      
        $this->clientCertHash=$hd->get_Value(); //получили ГОСТ хэш нашего сертификата
        unset($hd);

        $hd = new \CPHashedData();        
        $hd->set_Algorithm(CADESCOM_HASH_ALGORITHM_CP_GOST_3411_2012_256);
        $hd->set_DataEncoding(BASE64_TO_BINARY);
        $hd->Hash(base64_encode($message)); // посчитали ГОСТ хэш для $message
        $rs = new \CPRawSignature();
        $shash=$rs->SignHash($hd, $cert); // https://docs.cryptopro.ru/cades/reference/cadescom/cadescom_interface/irawsignaturesignhash :  Подпись для ключей ГОСТ Р 34.10-2001 возвращается как описано в разделе 2.2.2 RFC 4491 (http://tools.ietf.org/html/rfc4491#section-2.2.2), но в обратном порядке байт.
        $signed=base64_encode(strrev(hex2bin($shash))); // получили подписанный data hash

        $sign = str_replace("\n", '', $this->urlSafe($signed));
        return $sign;
    }
}

Теперь займемся получением и проверкой JWT токена:

    public function getToken_V3(string $code): string
    {
        $timestamp = $this->getTimeStamp();
        $state = $this->buildState();

        $this->signer = new SignerCPDataHash(
            $config->getCertPath(),
            $config->getPrivateKeyPath(),
            $config->getPrivateKeyPassword(),
            $config->getTmpPath()
        );

        $clientSecret = $this->signer->sign(
              $this->config->getClientId()
            . $this->config->getScopeString()
            . $timestamp
            . $state
            . $this->config->getRedirectUrl();
        );

        $body = [
            'client_id' => $this->config->getClientId(),
            'code' => $code,
            'grant_type' => 'authorization_code',
            'client_secret' => $clientSecret,
            'state' => $state,
            'redirect_uri' => $this->config->getRedirectUrl(),
            'scope' => $this->config->getScopeString(),
            'timestamp' => $timestamp,
            'token_type' => 'Bearer',
            'refresh_token' => $state,
            'client_certificate_hash' => $this->clientCertHash,
        ];

        $payload = $this->sendRequest(
            new Request(
                'POST',
                $this->config->getTokenUrl_V3(),
                [
                    'Content-Type' => 'application/x-www-form-urlencoded',
                ],
                http_build_query($body)
            )
        );

        $this->logger->debug('Payload: ', $payload);

        $token = $payload['access_token'];

        $chunks = explode('.', $token);
        $payload = json_decode($this->base64UrlSafeDecode($chunks[1]), true);
        $header = json_decode($this->base64UrlSafeDecode($chunks[0]), true);
        $_token_signature  = $this->base64UrlSafeDecode($chunks[2]);

        if ('JWT'==$header->typ) {
            $store = new \CPStore();            
            $store->Open(CURRENT_USER_STORE, "Users", STORE_OPEN_READ_ONLY); // используем хранилище Users
            $certs = $store->get_Certificates();
            $certlist = $certs->Find(CERTIFICATE_FIND_SHA1_HASH, $this->ESIACertSHA1, 0); // ищем в хранилище сертификат ЕСИА по его sha1 хэшу
            $cert = $certlist->Item(1);
            if (!$cert) {
                 throw new CannotReadCertificateException('Cannot read the certificate');
            }              
            
            $hd = new \CPHashedData();        
            $hd->set_DataEncoding(BASE64_TO_BINARY);
            switch ($header->alg) {
               case 'GOST3410_2012_256':
                   $hd->set_Algorithm(CADESCOM_HASH_ALGORITHM_CP_GOST_3411_2012_256);
                   break; 
               case 'GOST3410_2012_512':
                   $hd->set_Algorithm(CADESCOM_HASH_ALGORITHM_CP_GOST_3411_2012_512);
                   break;             
               default:
                   throw new Exception('Invalid signature algorithm');    
            }
            $hd->Hash(base64_encode($chunks[0].'.'.$chunks[1]));        
            $rs = new \CPRawSignature();
            $rs->VerifyHash($hd, bin2hex(strrev($_token_signature)), $cert);

            //если попали на эту строчку, значит подпись валидная. Иначе бы уже было вызвано исключение.
            $this->config->setOid($payload['urn:esia:sbj_id']);  
            $this->config->setToken($token);
        } // JWT token

        return $token;
    }

Ура. У нас теперь есть функции для работы с новыми endpoint ЕСИА: buildUrl_V2() и getToken_V3().

© Habrahabr.ru