Интеграция с ЕСИА на Node.js без CryptoPro [2024]

—

от автора

Приветствую! Свою первую статью решил посвятить технической стороне интеграции с ЕСИА (Госуслугами) без использования платной CryptoPro. Надеюсь данный материал поможет коллегам, столкнувшимся с этой задачей.

Предыстория

Совсем недавно в проекте который я разрабатываю встала задача идентифицировать пользователей и сохранять их верифицированные паспортные данные с дальнейшей целью формирования документов и соглашений с этими данными. Решили сделать авторизацию через ГосУслуги, т.к это крупнейшая доступная база паспортных данных в России. Первое что бросилось в глаза — нестандартное ГОСТовское шифрование и несвобода в выборе ПО для работы с этим шифрованием, а также отсутствие актуальных материалов и понятной документации. В следствие чего пришлось собирать информацию по крупицам, пробовать и экспериментировать на каждым шаге, на что ушло немало времени. Теперь когда все шаги пройдены и интеграция налажена, я решил осветить темные места, чтобы помочь разработчикам в их непростом деле.

Перед началом!

Обязательно проверьте подходит ли ваше юр лицо под критерии для подключения к ЕСИА. Это обязательное условие. Без этого Минцифры не одобрят заявку на интеграцию. Ваша компания должна иметь одну из следующих лицензий:

  • Государственные и муниципальные учреждения

  • Банки и платежные агенты

  • Микрофинансовые и микрокредитные компании

  • Страховые компании

  • Финансовые компании (профессиональные участники рынка ценных бумаг)

  • Операторы мобильной связи

  • Операторы финансовых платформ (маркетплейсы)

  • Операторы инвестиционных платформ (краудлендинг)

  • Телемедицинские компании

  • Ресурсоснабжающие и сетевые организаций

  • Кредитные потребительские кооперативы

    И пусть вас не вводит в заблуждение то, что вам выдадут тестовый доступ к ЕСИА. Это еще ничего не значит. Проверка лицензии компании происходит перед выдачей продакшн доступа к ЕСИА.

Первый этап

Получение обезличенной ЭЦП от аккредитованного УЦ. Такую подпись выдают в ФНС директору юр лица на специальный токен-флешку. Важно использовать Рутокен. Не буду подробно описывать этот процесс — в интернете много материалов на эту тему. Единственное скажу, что правильно воспользоваться именно обезличенной ЭЦП. С обычной ЭЦП тоже будет работать, но есть риск компрометации закрытого ключа директора компании. После того как получим ЭЦП необходимо загрузить сертификат в технологический портал ЕСИА. Инструкцию по тому как это сделать можете найти по ссылкам в конце статьи.

Второй этап

Извлечение ЭЦП из токена в файл. Для этого нужна программа: Tokens.exe (скачать работает только на Windows). Программа позволяет скопировать закрытый ключ из токена на компьютер в виде контейнера закрытого ключа. Контейнер представляет из себя папку с 6 файлами:

  • header.key

  • masks.key

  • masks2.key

  • name.key

  • primary.key

  • primary2.key

В этих файлах зашифрован приватный ключ и сертификат ЭЦП. Наша задача расшифровать эти файлы и перевести приватный ключ в формат PEM.

Третий этап

Теперь нужно конвертировать контейнер из предыдущего этапа в экспортируемый формат с помощью утилиты Certfix.exe (скачать работает только на Windows). На выходе получим такой же контейнер с 6 файлами, но он будет «экспортируемым».

Внимание! Данные программы пропали из официальных источников и распространяются в интернете хаотично. Важно не установить трояны вместе с этими программами. Чтобы минимизировать этот риск я скачал эти программы с разных источников и сверил их md5 хеш (Для CertFix 03437b073ab55aef499b0987f0297a86. Для Tokens c87092e98667944d4cf27e55f887b827). Все они совпали, что говорило о том что это одна и та же копия, а значит, скорее всего является оригинальной. Оставлю ссылку на эти программы ниже. Ответственности за них я не беру, поэтому пользуйтесь ими на свой страх и риск.

Четвертый этап

Самое интересное. Переведем контейнер с 6 файлами в привычный нам формат PEM. Для этого потребуется библиотека node-gost-crypto. Рекомендую загрузить ее отсюда и скопировать папку lib в свой проект. Также в корень проекта скопируйте контейнер с файлами и переименуйте его в container. Код для конвертации контейнера в PEM ключ и сертификат:

const fs = require('fs'); const { gostCrypto } = require('./lib');  const exportKeyFromContainer = async (password) => {     var keyContainer = new gostCrypto.keys.CryptoProKeyContainer({         header: fs.readFileSync('container/header.key').toString('base64'),         name: fs.readFileSync('container/name.key').toString('base64'),         primary: fs.readFileSync('container/primary.key').toString('base64'),         masks: fs.readFileSync('container/masks.key').toString('base64'),         primary2: fs.readFileSync('container/primary2.key').toString('base64'),         masks2: fs.readFileSync('container/masks2.key').toString('base64')     });      const key = await keyContainer.getKey(password);     const cert = await keyContainer.getCertificate();      return [key.encode('PEM'), cert.encode('PEM')].join('\n'); }  exportKeyFromContainer().then(console.log)

В консоли должны появиться приватный и публичный ключи в таком формате:

-----BEGIN PRIVATE KEY----- <<DATA>> -----END PRIVATE KEY----- -----BEGIN CERTIFICATE----- <<DATA>> -----END CERTIFICATE-----

Скопируйте и сохраните их в файлы final.key и final.crt в корне проекта. Если произошла ошибка, возможно нужно передать пароль от контейнера в функцию exportKeyFromContainer. Но у меня сработало и без этого.

Пятый этап

Подпись текста PEM ключом. Попробуем наш свежеиспеченный ключ в деле и попробуем что-нибудь им подписать.

const fs = require('fs'); const { gostCrypto } = require('./lib');  const sign = async (text) => {     var content = gostCrypto.coding.Chars.decode(text, 'utf-8');      var key = new gostCrypto.asn1.PrivateKeyInfo(fs.readFileSync('final.key').toString());     var cert = new gostCrypto.cert.X509(fs.readFileSync('final.crt').toString());      msg = new gostCrypto.cms.SignedDataContentInfo();     msg.setEnclosed(content);     msg.writeDetached(true);     msg.content.certificates = [cert];     await msg.addSignature(key, cert, false);      return Buffer.from(msg.encode('DER')) } sign('helloworld').then(res => res.toString('base64url')).then(console.log)

На выходе в консоли увидим длинную подпись. Пусть вас не смущает длина этой подписи — так должно быть.

Шестой этап

Самые сложные шаги позади. Мы научились формировать подпись от любой строки. Теперь дело техники — необходимо сформировать правильную строку для ЕСИА, подписать ее тем же способом, сформировать ссылку и отправить на фронт. Когда пользователь перейдет по этой ссылке и авторизуется, его перебросит обратно с параметром code в url. Из этого параметра мы получим accessToken, который в свою очередь откроет нам доступ к личным данным пользователя.

Опубликую Nest.js модуль, который выполняет всю эту работу:

// esia.service.ts  import { Injectable, InternalServerErrorException } from '@nestjs/common'; import moment from 'moment'; import { v4 as uuid } from 'uuid'; import { gostCrypto } from './lib'; import { config } from 'src/config'; import axios from 'axios'; import { verify } from 'jsonwebtoken';  export type EsiaTokens = {   idToken: string;   accessToken: string; };  export type EsiaParsedToken = {   'urn:esia:sbj': {     'urn:esia:sbj:oid': string;   }; };   @Injectable() export class EsiaService {   scope = [     'openid',     'fullname',     'email',     'gender',     'mobile',     'birthdate',     'id_doc',   ];    async signText(text: string) {     const content = gostCrypto.coding.Chars.decode(text, 'utf-8');      const key = new gostCrypto.asn1.PrivateKeyInfo(config.esiaClientKey);     const cert = new gostCrypto.cert.X509(config.esiaClientCrt);      const msg = new gostCrypto.cms.SignedDataContentInfo();     msg.setEnclosed(content);     msg.writeDetached(true);     msg.content.certificates = [cert];     await msg.addSignature(key, cert, false);      return Buffer.from(msg.encode('DER'));   }    private async signParams(params: Record<string, string>) {     const scope = this.scope.join(' ');     const time = moment().format('YYYY.MM.DD HH:mm:ss ZZ');     const clientId = config.esiaClientId;     const state = uuid();     const clientSecret = await this.signText(       [scope, time, clientId, state].join(''),     );      return {       ...params,       timestamp: time,       client_id: clientId,       scope: scope,       state,       client_secret: clientSecret.toString('base64url'),     };   }    async getAuthLink(redirectLink: string) {     const params = await this.signParams({       redirect_uri: redirectLink,       response_type: 'code',       access_type: 'offline',     });      const authQuery = new URLSearchParams(params);     const authURL = `${config.esiaHost}/aas/oauth2/ac`;     return `${authURL}?${authQuery}`;   }    async getTokens(code: string) {     try {       const params = await this.signParams({         grant_type: 'authorization_code',         token_type: 'Bearer',         redirect_uri: 'no',         code,       });       const authURL = `${config.esiaHost}/aas/oauth2/te`;       const authQuery = new URLSearchParams(params);       const { data: tokens } = await axios.post(`${authURL}?${authQuery}`);       return {         idToken: tokens.id_token,         accessToken: tokens.access_token,         refreshToken: tokens.refresh_token,       };     } catch (e) {       const status = e.response ? e.response.status : 500;       const message = e.response         ? e.response.data.error_description         : e.message;       throw new InternalServerErrorException(         'Failed to get auth tokens: ' + message,         status,       );     }   }    getUserIdFromToken(idToken: string) {     const decodedIdToken = verify(idToken, config.esiaCrt, {       algorithms: ['RS256'],       audience: config.esiaClientId,     }) as EsiaParsedToken;     return decodedIdToken['urn:esia:sbj']['urn:esia:sbj:oid'];   }    async getUserInfo(tokens: EsiaTokens) {     const { idToken, accessToken } = tokens;     const oId = this.getUserIdFromToken(idToken);      const [{ data: main }, { data: contacts }, { data: docs }] =       await Promise.all([         axios.get(`${config.esiaHost}/rs/prns/${oId}`, {           headers: {             Authorization: `Bearer ${accessToken}`,           },         }),         axios.get(`${config.esiaHost}/rs/prns/${oId}/ctts?embed=(elements)`, {           headers: {             Authorization: `Bearer ${accessToken}`,           },         }),         axios.get(`${config.esiaHost}/rs/prns/${oId}/docs?embed=(elements)`, {           headers: {             Authorization: `Bearer ${accessToken}`,           },         }),       ]);      return [main, contacts, docs];   } } 

Для верификации ответов от ЕСИА необходимо загрузить публичный ключ ЕСИА (в коде выше это config.esiaCrt) с официального источника — сайта Минцифр.

Надеюсь данная статья будет полезной читателям. Пишите комментарии получилось ли у вас настроить интеграцию с ЕСИА.

Полезные материалы:


ссылка на оригинал статьи https://habr.com/ru/articles/811389/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *