Integration with ESIA v2 on Debian 11 + php 7

I was given the task “so that visitors can enter the site through the State Services.” The problem is not new, it has long been solved. In PHP, they use librariesbut there are a couple of caveats.

Problem 1

From January 2020, only GOST encryption is required for integration. Solution options:

  • pull an external utility that can GOST

  • build php with GOST support

Both options require the use of a licensed cryptographic tool (CryptoPro), in the framework of the article we will go along the second path. All php can not be rebuilt, because there is a plugin mechanism. Therefore, it is enough to build the plugin libphpcades from CryptoPro.

For Debian 11 and php 7.4 instructions from CryptoPro, I had to slightly modify it with a file. Steps 2 and 6 are skipped, just install the php-dev package. After that, at step 7 in Makefile.unix, you can set the path to the header files from the php-dev package

PHPDIR=/usr/include/php/20190902

Next, in step 8, download the php7 patch and apply it to the libphpcades source files:

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

Step “8 and 3/4”: To build the library under debian 11, add to compiler options CFLAGS flag

-fpermissive

And from the LDFLAGS build parameters delete flag

-lcplib

And after that the library is successfully assembled. In a good way, now the compiled libphpcades.so should be packaged in a deb package…

So in step 11, instead of creating a symlink, we simply copy the compiled library to the company with the rest of the php modules

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

After that, we no longer need the source files, they can be deleted.

For the test circuit ESIA GOST-certificate can be generate yourselffor industrial – you will have to get it from an authorized Certification Center (OGRN in the certificate must match the OGRN of your IP in the ESIA industrial circuit)

In any way, we transfer the key and certificate of our IP to /var/opt/cprocsp/keys/www-data/MYSITE.000you can simply copy it, you can use the command

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

Next, install the certificate and its private key in the My store of the www-data user:

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

During the installation process, we check that there is a connection between the certificate and the private key in the container:

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

We install the open ESIA certificate in the Users container of the user 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

Problem 2

Since version 2.90 methodological recommendations the /aas/oauth2/ac endpoint used is deprecated and deprecated. It is proposed to switch to /aas/oauth2/v2/ac, but there is no implementation in the specified libraries.

This is what we are going to do now. We take out the file again and apply it, for example, to the library https://github.com/fr05t1k/esia

Add new configuration properties:

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; 
}

based method buildUrl() create a method buildUrl_V2()which will work with the endpoint of $codeUrlPath_V2

In the “v2/ac” endpoint, the composition and order of parameter concatenation for composing client_secret has changed (now it’s ClientID + Scope + Timestamp +State + RedirectURI) and client_certificate_hash has been added.

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); 
}

Yes, client_certificate_hash can be calculated by the utility proposed in the Guidelines and entered as another configuration parameter. Perhaps that is even correct. But why not learn how to calculate it yourself?

So, we create the SignerCPDataHash class, which uses libphpcades to calculate the GOST hash of the IP certificate (parameter client_certificate_hash) and signing client_secret according to the data hash algorithm.

For this, a certificate with CN=ClientID from the user storage My of the installed CryptoPro is used.

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;
    }
}

Now let’s get on with getting and validating the JWT token:

    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;
    }

Hooray. We now have functions for working with the new ESIA endpoints: buildUrl_V2() и getToken_V3().

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *