Приветствую! Свою первую статью решил посвятить технической стороне интеграции с ЕСИА (Госуслугами) без использования платной 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
) с официального источника - сайта Минцифр.
Надеюсь данная статья будет полезной читателям. Пишите комментарии получилось ли у вас настроить интеграцию с ЕСИА.
Полезные материалы:
sstv
Прежде чем использовать эту штуку, её нужно сертифицировать в ФСБ т.к. это по сути является СКЗИ (реализует ГОСТ алгоритмы).
Плюс там не мало требований к самой эксплуатации :) По большому счету из-за этого все и используют платные, т.к. они сертифицированные и проблем с ними не возникнет :)
yoshitoshi
У нас тип организации — «фонд», есть связь с некоторыми «департаментами * Москвы». Была у нас идея использовать ЕСИА для электронного подписания документов. Но, насколько я знаю, идея не то, что «не взлетела», она даже «от терминала не отъехала».
Самая большая проблема была в том, что мы нигде не могли найти подробностей, как это вообще должно работать, к кому и куда обращаться, какие программные реализации допустимо использовать. И если доступ к есиа мы и сможем получить, то не очень понятно, что с ним дальше-то делать?
И вот, Вы упомянули, что «все … используют платные». Если это не секрет, не подскажете, что это за «платные штуки»? Это не что-то в роде мегафноID? Ну, или, может быть знаете, в какую компанию обращаться, где все это уже умеют делать?
sstv
Калуга Астрал, Астрал-СОФТ, Тензор, Контур, Тинькофф, Такском, 1С - эти компании на ГОСТ алгоритмах собаку съели на ЭДО и не только :) Есть еще ИнфоТеКС которая разрабатывает один из криптопровайдеров VipNet. Или можно сразу к криптопро :)
yoshitoshi
То есть, все перечисленные занимаются реализацией подобных проектов на заказ? И можно просто обратиться, например, в тот же Такском? О «Контуре», если честно, по опыту только самые негативные впечатления, по крайней мере, об их API-подразделении. А «1С» - понятно, не сама, а кто-то из партнеров? «Рарус»?