more confusing than it seems


Foreword

I was faced with the task of integrating our service with public services. It seemed that nothing complicated was ahead, but given that our service is based on ASP.NET technology, everything was not so optimistic. In the beginning there were searches .. a lot of searches that led to a lot of scattered and most often irrelevant information. Ready-made solutions were also found, but as some comrades on the forums stated, they can pat on the head for this. Therefore, it was decided to write myself.

This article is more of an update and addition of information from this article.

Introduction

On the website of the Ministry of Digital Development there is a manual that is as bloated and very confusing as possible, but we still have to use it. We will work with ESIA version 3.11 (current at the time of this writing). Briefly, our actions are as follows:

  1. Registration of IP in the register of information systems ESIA

  2. IP registration in a test environment

  3. Implementation of the system refinement for interaction with the ESIA

It sounds pretty simple, but each step is a whole separate story of adventure. IP registration in ESIA is an adventure for a bureaucrat. Therefore, in this article we will take a little look at the second step, and describe the implementation in detail.

Content

Everything needed

Mintsifry require the use of certified cryptographic software. Therefore we will use CryptoPRO CSP + CryptoPRO .Net + CryptoPRO .NetSDK. All this can be downloaded from off. CryptoPRO site. During development, it is better to use the trial version.

Our travel inventory:

  • CryptoPRO CSP

  • CryptoPRO .Net

  • CryptoPRO .NetSDK

  • Private key container with our organization’s certificate

  • A lot of patience

A little about CryptoPRO CSP + .Net Core 5+

This is where the first problems begin. At the time of this writing, CryptoPRO .Net does not support .Net Core 5 and higher. There is an assembly for .Net Core 3.1, but it also looks doubtful. Therefore, it was decided to raise a service for the .Net Framework 4.8 that will use the CryptoPRO CSP tools for signing using EDSas well as checking answers from the ESIA.

A little about the private key and certificate container

When we started doing this task, we had CEP on the token, but as it turned out, it had a non-exportable container. I will say right away that exporting a container from such a token is prohibited by the Federal Tax Service. Therefore, it is necessary to obtain a token in advance in the name of an employee with an exported container. Since it will need to be copied to the server.

Getting Started

Let’s start with the fact that you have already sent an application for registration of IP to the ESIA and it was accepted. And also sent an application for a test environment. Let’s proceed to the stage of setting up the IS in the e-government test cabinet. Here link to the test page. We log in under the test account of test user 006 (all data is in the application for working with the test environment), since he has access to IP management.

Test environment cabinet - Technology portal
Test environment cabinet – Technology portal

Here we are looking for our system by Mnemonic or full name, if there is none, then we create it. Opposite our system there are two buttons:
The first button is to change our IP (IP information, redirects, etc.)
The second button is our certificates with which we sign messages in the ESIA

IP setting

There is an important point in setting up the IP. This is the system URL. Here we indicate links where ESIA can redirect when requested from our IP. An authorization code will be sent to these points (if it is specified in the request).

IP certificates

Here we can download our certificates or delete them. There is one important point, each IP can have only one unique certificate. And due to the fact that in the test environment all systems are registered under one user and test certificates are the same for all, it is often such a situation that someone deletes your certificate and uploads it to themselves. And now your requests are failing with an error) But if you already have a digital signature for an employee, then it’s better to use it.

We implement

We are done with setting up our IP and can start implementing. I hope you have already installed CryptoPRO and everything you need for it. If not, I’ll wait…

Installing certificates

Taxi~ Everything is ready. We download certificates from the link from the manual. I won’t include it on purpose, as it may change.

Here we are interested in the TESIA GOST 2012.cer certificate – this is a certificate with which the ESIA signs messages by sending them to our IS. (Accordingly, for the product environment, your certificate). Install the certificate as trusted. There is nothing complicated here, I think you will understand.

Now install the test container and certificate. For example, we will use the containers provided by the ESIA, but you can use your own. All this is inside the archive.

The archive itself with all test containers
The archive itself with all test containers
We will take exactly 006, since our IP is registered on it
We will take exactly 006, since our IP is registered on it

The archive contains the folder d1f73ca5.000 – this is the container we need to move it along the path C:\Users\User\AppData\Local\Crypto Pro

Now open CryptoPRO CSP. We choose to install a personal certificate and specify the Test Office Surname006 IO.cer and click find automatically. Let’s do the rest of the steps.

Signing mechanism

Perhaps the most important and most confusing part of the whole journey begins. Here we implement a service for working with a signature. And so I make a squeeze from methodological materials so that you do not have to read a lot of text.

To receive an authorization link – a link to which we will redirect the user for authorization in the ESIA. We need to collect the link from the parameters.

  1. client_id – our Mnemonic

  2. client_secret – Detached signature from request parameters in UTF-8 encoding

  3. redirect_uri – a link to which ESIA will redirect the user along with an authorization code

  4. scope – list of requested information. For example fullname birthdate gender

  5. response_type – type of response from ESIA, in our case it’s just a line code

  6. state – Identifier of the current request. generated like this Guid.NewGuid().ToString("D");

  7. timestamp – authorization code request time in the format yyyy.MM.dd HH:mm:ss Z. Generated in this way DateTime.UtcNow.ToString("yyyy.MM.dd HH:mm:ss +0000");

  8. client_certificate_hash is the fingerprint of the certificate in HEX format.

Designated our zoo. The most important beast is here client_secret

Get client_certificate_hash

In the methodical instruction from the Ministry of Digital Values ​​there is link to special tool with which we can get this hash. We unzipped the archive and we see sh. Windows users are not afraid, in fact, there is an .exe file right there. To calculate the hash of our certificate, you simply need to run the following script from cmd:

cpverify.exe test.cer -mk -alg GR3411_2012_256 -inverted_halfbytes 0

Forming client_secret

Tax before you just get client_secret we need to do:

Let’s skip the many steps of creating this service and go straight to setting it up to work with CryptoPRO CSP.

Setting up a service to work with CryptoPRO CSP

Now let’s create a controller:

Controller code

So we need to get a string for signing. Let’s create a method

const string CertSerialNumber = "01f290e7008caed0904b967783fd0e4ad6";
const string EsiaCertSerialNumber = "0125657e00a1ae59804d92116214e53466";

[HttpGet]
public string Get(string msg)
{
    msg = Base64UrlEncoder.Decode(msg);

    var data = Encoding.UTF8.GetBytes(msg);

    var client_secret = Sign(data);

    return client_secret;
}

We will indicate in advance the serial numbers of the certificates as constants.
In the Get method, we get a string in Base64Url format in order to safely transfer our long messages.
We decode the string from Base64Url to text. Then we translate the text into bytes using UTF-8. And now we sign.

string Sign(byte[] data)
{
    var gost3411 = new Gost3411_2012_256CryptoServiceProvider();
    var hashValue = gost3411.ComputeHash(data);
    gost3411.Clear();
    var signerCert = GetSignerCert();
    var SignedHashValue = GostSignHash(hashValue,
        signerCert.PrivateKey as Gost3410_2012_256CryptoServiceProvider, "Gost3411_2012_256");
    var client_secret = Base64UrlEncoder.Encode(SignedHashValue);

    return client_secret;
}

And so what are we doing here. Using GOST 34.11-2012, we calculate the hash of our message. And using the received certificate we sign the message.

X509Certificate2 GetSignerCert()
{
    var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
    store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadOnly);
    var certificates = store.Certificates.Find(X509FindType.FindBySerialNumber, CertSerialNumber, false);

    if (certificates.Count != 1)
    {
        return null;
    }

    var certificate = certificates[0];

    if (certificate.PrivateKey == null)
    {
        return null;
    }

    return certificate;
}

Here we open our warehouse with containers and look for exactly the one where our certificate lies. Then we extract the certificate from it.

byte[] GostSignHash(byte[] HashToSign, Gost3410_2012_256CryptoServiceProvider key, string HashAlg)
{
    try
    {
        //Создаем форматтер подписи с закрытым ключом из переданного 
        //функции криптопровайдера.
        var Formatter = new Gost2012_256SignatureFormatter(
            (Gost3410_2012_256CryptoServiceProvider) key);

        //Устанавливаем хэш-алгоритм.
        Formatter.SetHashAlgorithm(HashAlg);

        //Создаем подпись для HashValue и возвращаем ее.
        return Formatter.CreateSignature(HashToSign);
    }
    catch (CryptographicException e)
    {
        Console.WriteLine(e.Message);
        return null;
    }
}

With the help of this code, our signature on the hash of the string is created. GOST 34.10-2012 is used here.

So the controller is ready. Now let’s go to our main project on .Net Core

Create a signature string. We just concatenate the parameters without delimiters. Here I am using IOptions to take options from appsettings.json.

var msg = $"{esiaSettings.Value.ClientId}{esiaSettings.Value.Scope}{timestamp}{state}{redirectUri}";

We’ve got a string to sign. Now we need to encode this string in Base64Url and send it for signing to the service we wrote in advance

private string GetClientSecret(string msg){
  var client = new HttpClient();

  var msgBase64 = Base64UrlEncoder.Encode(msg);
  
  var response = await client.GetAsync($"{cryptoProSettings.Value.BaseUrl}/Get?msg={msgBase64}");
  
  var clientSecret = await response.Content.ReadAsStringAsync();
  
  clientSecret = JsonConvert.DeserializeObject<string>(clientSecret);
  
  return clientSecret;
}

We collect a link for authorization in the State Services

Finally, we got this long-awaited secret. But you might think that’s all, then everything is simple and clear. It wasn’t there! The fact is that ESIA requires Base64 Url Safe encoding. And it is slightly different from the Base64Url encoding available from the .Net box
So the matter is small, we collect our homunculus from the secret and parameters.

Reference assembly helper class

Perhaps overkill, but I liked the collection method in this way.

public class RequestBuilder
{
    List<RequesItemClass> items = new List<RequesItemClass>();
    public void AddParam(string name, string value)
    {
        items.Add(new RequesItemClass { name = name, value = value });
    }
    public override string ToString()
    {
        return string.Join("&", items.Select(a => a.name + "=" + a.value));
    }
}

public class RequesItemClass
{
    public string name;
    public string value;
}
Link assembly code
async Task<string> UrlBuild(string redirectUri)
{
    using var client = new HttpClient();

    var timestamp = DateTime.UtcNow.ToString("yyyy.MM.dd HH:mm:ss +0000");
    var state = Guid.NewGuid().ToString("D");

    var msg = $"{esiaSettings.Value.ClientId}{esiaSettings.Value.Scope}{timestamp}{state}{redirectUri}";

    var clientSecret = await GetClientSecret(msg);

    var builder = new RequestBuilder();
    builder.AddParam("client_secret", clientSecret);
    builder.AddParam("client_id", esiaSettings.Value.ClientId);
    builder.AddParam("scope", esiaSettings.Value.Scope);
    builder.AddParam("timestamp", timestamp);
    builder.AddParam("state", state);
    builder.AddParam("redirect_uri", redirectUri);
    builder.AddParam("client_certificate_hash", esiaSettings.Value.ClientCertificateHash);
    builder.AddParam("response_type", "code");
    builder.AddParam("access_type", "online");

    //Вот тут самый важный момент на который было потрачено множество времени. Просто заменяем символы на безопасные
    var url = esiaSettings.Value.EsiaAuthUrl + "?" + builder.ToString().Replace("+", "%2B")
        .Replace(":", "%3A")
        .Replace(" ", "+");

    return url;
}

We get a link like this:
Here https://esia-portal1.test.gosuslugi.ru/aas/oauth2/v2/ac a link to the endpoint for obtaining an authorization code is indicated in the methodological material.

https://esia-portal1.test.gosuslugi.ru/aas/oauth2/v2/ac?client_secret=v_c33_-LpkyKJbopTEYqBMbGZrBy9r9u1pzbRmMLNlJPcBnPTJj6Xx5DuxXba3EZZoXdMsb0YIwPDCoF0dfYjQ&client_id=MEMONIKA&scope=fullname+birthdate+gender&timestamp=2022.12.23+16%3A37%3A45+%2B0000&state=3a19c4d7-594b-496f-aa6e-970c75a925a4&redirect_uri=https%3A//api.site/users/esia&client_certificate_hash=EED1079A4FF154E117EAA196DCB551930807825DE1DE15EAF7607F354BA47423&response_type=code&access_type=online

Now we redirect the user to this link and wait until he logs in. After authorization, the ESIA will redirect it to our link and send the authorization code and state as arguments there.

Getting an access token

Now it’s time to get a token in exchange for an authorization code.

Method for getting a token
public async Task<EsiaAuthToken> GetToken(string authorizationCode, string redirectUrl)
{
    var timestamp = DateTime.UtcNow.ToString("yyyy.MM.dd HH:mm:ss +0000");
    var state = Guid.NewGuid().ToString("D");

    var msg =
        $"{esiaSettings.Value.ClientId}{esiaSettings.Value.Scope}{timestamp}{state}{redirectUrl}{authorizationCode}";

    var clientSecret = await GetClientSecret(msg);

    var requestParams = new List<KeyValuePair<string, string>>
    {
        new KeyValuePair<string, string>("client_id", esiaSettings.Value.ClientId),
        new KeyValuePair<string, string>("code", authorizationCode), //Здесь мы передаём полученный код
        new KeyValuePair<string, string>("grant_type", "authorization_code"),  //Просто указываем тип
        new KeyValuePair<string, string>("state", state),
        new KeyValuePair<string, string>("scope", esiaSettings.Value.Scope),
        new KeyValuePair<string, string>("timestamp", timestamp),
        new KeyValuePair<string, string>("token_type", "Bearer"),  //Какой токен мы хотим получить
        new KeyValuePair<string, string>("client_secret", clientSecret),
        new KeyValuePair<string, string>("redirect_uri", redirectUrl),
        new KeyValuePair<string, string>("client_certificate_hash", esiaSettings.Value.ClientCertificateHash)
    };

    using var client = new HttpClient();
    using var response = await client.PostAsync(esiaSettings.Value.EsiaTokenUrl,
        new FormUrlEncodedContent(requestParams));
    response.EnsureSuccessStatusCode();
    var tokenResponse = await response.Content.ReadAsStringAsync();

    var token = JsonConvert.DeserializeObject<EsiaAuthToken>(tokenResponse);

    if (!await ValidatingAccessToken(token))
    {
        throw new Exception("Ошибка проверки маркера индентификации");
    }

    return token;
}

Everything is simple here, we generate again client_secret specify the remaining parameters and send a request to the ESIA to receive a token. Test Uri https://esia-portal1.test.gosuslugi.ru/aas/oauth2/v3/te

Token class
public class EsiaAuthToken
{
    /// <summary>
    /// Токен доступа
    /// </summary>
    [JsonProperty("access_token")]
    public string AccessToken { get; set; }

    /// <summary>
    /// Идентификатор запроса
    /// </summary>
    public string State { get; set; }

    string[] parts => AccessToken.Split('.');

    /// <summary>
    /// Хранилище данных в токене
    /// </summary>
    public EsiaAuthTokenPayload Payload
    {
        get
        {
            if (string.IsNullOrEmpty(AccessToken))
            {
                return null;
            }

            if (parts.Length < 2)
            {
                throw new Exception($"При расшифровке токена доступа произошла ошибка. Токен: {AccessToken}");
            }

            var payload = Encoding.UTF8.GetString(Base64UrlEncoder.DecodeBytes(parts[1]));
            return JsonConvert.DeserializeObject<EsiaAuthTokenPayload>(payload);
        }
    }

    /// <summary>
    /// Сообщение для проверки подписи
    /// </summary>
    [Newtonsoft.Json.JsonIgnore]
    public string Message
    {
        get
        {
            if (string.IsNullOrEmpty(AccessToken))
            {
                return null;
            }

            if (parts.Length < 2)
            {
                throw new Exception($"При расшифровке токена доступа произошла ошибка. Токен: {AccessToken}");
            }

            return parts[0] + "." + parts[1];
        }
    }

    /// <summary>
    /// Сигнатура подписи
    /// </summary>
    [Newtonsoft.Json.JsonIgnore]
    public string Signature
    {
        get
        {
            if (string.IsNullOrEmpty(AccessToken))
            {
                return null;
            }

            if (parts.Length < 2)
            {
                throw new Exception($"При расшифровке токена доступа произошла ошибка. Токен: {AccessToken}");
            }

            return parts[2];
        }
    }

    public class EsiaAuthTokenPayload
    {
        [JsonConstructor]
        public EsiaAuthTokenPayload(string tokenId, string userId, string nbf, string exp, string iat, string iss,
            string client_id)
        {
            TokenId = tokenId;
            UserId = userId;
            BeginDate = EsiaHelper.DateFromUnixSeconds(double.Parse(nbf));
            ExpireDate = EsiaHelper.DateFromUnixSeconds(double.Parse(exp));
            CreateDate = EsiaHelper.DateFromUnixSeconds(double.Parse(iat));
            Iss = iss;
            ClientId = client_id;
        }

        /// <summary>
        /// Идентификатор токена
        /// </summary>
        [JsonProperty("urn:esia:sid")]
        public string TokenId { get; private set; }

        /// <summary>
        /// Идентификатор пользователя
        /// </summary>
        [JsonProperty("urn:esia:sbj_id")]
        public string UserId { get; private set; }

        /// <summary>
        /// Время начала действия токена
        /// </summary>
        [JsonPropertyName("nbf")]
        public DateTime BeginDate { get; private set; }

        /// <summary>
        /// Время окончания действия токена
        /// </summary>
        [JsonPropertyName("exp")]
        public DateTime ExpireDate { get; private set; }

        /// <summary>
        /// Время выпуска токена
        /// </summary>
        [JsonPropertyName("iat")]
        public DateTime CreateDate { get; private set; }

        /// <summary>
        /// Организация, выпустившая маркер
        /// </summary>
        [JsonPropertyName("iss")]
        public string Iss { get; private set; }

        /// <summary>
        /// Адресат маркера
        /// </summary>
        [JsonPropertyName("client_id")]
        public string ClientId { get; private set; }
    }
  }

  public static class EsiaHelper
  {
      public static DateTime DateFromUnixSeconds(double seconds)
      {
          var date = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc);

          return date.AddSeconds(seconds).ToLocalTime();
      }
  }

Token verification

So besides the fact that we need to get a token, we also need to verify it.

The token itself consists of 3 parts.
1 part – JWT token header
Part 2 – payload of the token, there is all the basic information about the token
Part 3 – RAW signature in UTF-8 format

Endpoint code for signature verification
[HttpPost]
public bool Verify(VerifyMessage message)
{
    try
    {
        return VerifyRawSignString(message.Message, message.Signature);
    }
    catch (Exception ex)
    {
        return false;
    }
}

public class VerifyMessage
{
    public string Signature { get; set; }
    public string Message { get; set; }
}
Signature verification code on our service
/// <summary>
/// Проверка подписи JWT в формате HEADER.PAYLOAD.SIGNATURE.
/// </summary>
/// <param name="message">HEADER.PAYLOAD в формате Base64url</param>
/// <param name="signature">SIGNATURE в формате Base64url</param>
bool VerifyRawSignString(string message, string signature)
{
    var signerCert = GetEsiaSignerCert();

    var messageBytes = Encoding.UTF8.GetBytes(message);
    var signatureBytes = Base64UrlEncoder.DecodeBytes(signature);
    //Переварачиваем байты, так как используется RAW подпись
    Array.Reverse(signatureBytes, 0, signatureBytes.Length);

    using (var GostHash = new Gost3411_2012_256CryptoServiceProvider())
    {
        var csp = (Gost3410_2012_256CryptoServiceProvider) signerCert.PublicKey.Key;
        //Используем публичный ключ сертификата для проверки  
        return csp.VerifyData(messageBytes, GostHash, signatureBytes);
    }
}
ESIA certificate receipt code
X509Certificate2 GetEsiaSignerCert()
{
    var store = new X509Store(StoreName.AddressBook, StoreLocation.CurrentUser);
    store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadOnly);
    var certificates = store.Certificates.Find(X509FindType.FindBySerialNumber, EsiaCertSerialNumber, false);

    var certificate = certificates[0];

    return certificate;
}

Here we use the constants introduced earlier. And We get a certificate from trusted certificates.

Sending a token for verification
public async Task<bool> ValidatingAccessToken(EsiaAuthToken token)
{
    if (token.Payload.ExpireDate <= DateTime.Now ||
        token.Payload.BeginDate >= DateTime.Now ||
        token.Payload.CreateDate >= DateTime.Now ||
        token.Payload.ExpireDate <= token.Payload.BeginDate ||
        token.Payload.CreateDate > token.Payload.BeginDate ||
        token.Payload.CreateDate > token.Payload.ExpireDate ||
        token.Payload.Iss != esiaSettings.Value.ISS ||
        token.Payload.ClientId != esiaSettings.Value.ClientId)
    {
        return false;
    }

    var client = new HttpClient();

    var requestParams = new List<KeyValuePair<string, string>>
    {
        new KeyValuePair<string, string>("signature", token.Signature),
        new KeyValuePair<string, string>("message", token.Message)
    };

    var response = await client.PostAsync($"{cryptoProSettings.Value.BaseUrl}/Verify",
        new FormUrlEncodedContent(requestParams));

    response.EnsureSuccessStatusCode();
    var resultResponse = await response.Content.ReadAsStringAsync();

    var result = JsonConvert.DeserializeObject<bool>(resultResponse);

    return result;
}

We use this code in our main service.
We check the fields of the token for relevance so that it cannot be faked. And then we check the signature of the token, as indicated in the guidelines.

Receiving user data from ESIA

Having a token, we can send a request to obtain data about the user specified in the scope of the token. Sample code where we get user data. Here esiaUserId is contained in the token itself, this is the unique identifier of the ESIA user. We specify our token in the authorization header.

public async Task<EsiaUser> ExecuteAsync(string esiaUserId, string accessToken)
{
    using (var client = new HttpClient())
    {
        client.DefaultRequestHeaders.Clear();
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);

        var response = await client.GetStringAsync($"{esiaSettings.Value.EsiaRestUrl}/prns/{esiaUserId}");
        var user = JsonConvert.DeserializeObject<EsiaUser>(response);
        user.Id = user.Id ?? esiaUserId;

        return user;
    }
}
EsiaUser class code
public class EsiaUser
{
    /// <summary>
    /// Идентификатор
    /// </summary>
    [JsonProperty("oid")]
    public string Id { get; set; }

    /// <summary>
    /// Фамилия
    /// </summary>
    [JsonProperty("firstName")]
    public string FirstName { get; set; }

    /// <summary>
    /// Имя
    /// </summary>
    [JsonProperty("lastName")]
    public string LastName { get; set; }

    /// <summary>
    /// Отчество
    /// </summary>
    [JsonProperty("middleName")]
    public string MiddleName { get; set; }

    /// <summary>
    /// Дата рождения
    /// </summary>
    [JsonProperty("birthdate")]
    public string Birthdate { get; set; }

    /// <summary>
    /// Пол
    /// </summary>
    [JsonProperty("gender")]
    public string Gender { get; set; }

    /// <summary>
    /// Подтвержден ли пользователь
    /// </summary>
    [JsonProperty("trusted")]
    public bool Trusted { get; set; }
}

Conclusion

Finally, we have completed the integration with ESIA. It’s been a long road full of strange things. Unclear decisions and a lot of wasted time. I hope this article has helped you to implement the task of integration much faster and easier. Thank you for your time.

Similar Posts

Leave a Reply

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