Анализ содержимого QR кодов в документах электронного правительства РК во фронтенде

от автора

Я продемонстрирую как с помощью JavaScript прямо в браузере можно извлечь и проанализировать данные из QR кодов содержащихся в документах сформированных порталами электронного правительства Республики Казахстан (к примеру https://egov.kz).

В электронных документах присутствует следующая формулировка:

*штрих-код содержит данные, полученные из информационной системы ГБД РН и подписанные электронно-цифровой подписью Филиала НАО «Государственная корпорация «Правительство для граждан».

На сколько мне известно, готовых инструментов для извлечения и анализа данных в QR кодах не существует.

Конечную цель ставлю следующую: извлечь подписанные данные и подпись, проверить целостность подписанных данных. О проверке цифровой подписи в этой заметке речи идти не будет, только о проверке хеша. Детали проверки цифровых подписей могут быть описаны в будущем, в том случае, если публика проявит интерес к этой тематике.

Важно: эта заметка не описывает методик взлома и не помогает получать несанкционированного доступа к данным, речь будет идти о конвертации данных из одних представлений в другие.

Я покажу как обрабатывать оригинальные PDF файлы которые формируют и предоставляют для скачивания порталы электронного правительства РК. Эти PDF файлы содержат QR коды как отдельные внедренные изображения.

Экспериментировать я буду на справке об отсутствии судимости.

Я воспользуюсь следующими библиотеками:

  • PDF.js для извлечения изображений из PDF документа;
  • jsQR для декодирования QR кодов;
  • JSZip для распаковки ZIP файлов;
  • XMLDSIGjs для обработки XML;
  • WebCrypto GOST (gostCrypto) для вычисления хешей и кодирования/декодирования данных.

0. Считывание PDF файла в ArrayBuffer

Получить доступ к PDF файлу возможно стандартными средствами HTML с помощью тега <input type="file"> и его атрибута files.

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

const fileContents = await fileInput.files[0].arrayBuffer();

1. Извлечение изображений из PDF документа

Библиотеку PDF.js необходимо инициализировать перед началом работы, примеры приведены в документации https://mozilla.github.io/pdf.js/examples/index.html#interactive-examples

const pdfjsLib = window['pdfjs-dist/build/pdf']; pdfjsLib.GlobalWorkerOptions.workerSrc = 'pdf.worker.js';

В PDF.js объекты описываются с точки зрения выполняемых над ними операций. Так как меня интересуют изображения, то нужно искать следующие операции:

const ops = [   pdfjsLib.OPS.paintJpegXObject,   pdfjsLib.OPS.paintImageXObject, ];

Реализация извлечения изображений со всех страниц PDF документа:

const loadingTask = pdfjsLib.getDocument(fileContents); const pdf = await loadingTask.promise;  const objIDs = []; const images = [];  await (async function () {   for (let pageIndex = 1; pageIndex <= pdf.numPages; pageIndex += 1) {     const page = await pdf.getPage(pageIndex);      // Страница содержит набор операторов, нужно найти интересующие.     const operators = await page.getOperatorList();     for (let i = 0; i < operators.fnArray.length; i++) {       const fn = operators.fnArray[i];        if (ops.indexOf(fn) !== -1) {         // По индексу оператора можно получить его параметры, первый параметр - идентификатор объекта.         const objID = operators.argsArray[i][0];          // Над одним и тем же объектом могут выполняться несколько операций, дубликаты не нужны.         if (objIDs.indexOf(objID) === -1) {           objIDs.push(objID);            // Объект изображения можно получить по его идентификатору.           try {             const imageInfo = page.objs.get(objID);             images.push(imageInfo);           } catch (err) {             console.log(err);           }         }       }     }   } })()

2. Декодирование QR кодов

Библиотека jsQR поддерживает изображения только в RGBA в то время как в PDF файлы они могут быть внедрены и как RGB, потребуется функция приводящая RGB к RGBA:

function extractRGBAData(image) {   if (image.kind === 3) { // ImageKind.RGBA_32BPP из https://github.com/mozilla/pdf.js/blob/master/src/shared/util.js     return image.data;   }    if (image.kind !== 2) { // ImageKind.RGB_24BPP из https://github.com/mozilla/pdf.js/blob/master/src/shared/util.js     throw new Error(`Image kind "${image.kind}" is not supported.`);   }    const data = new Uint8ClampedArray(image.width * image.height * 4);    let destPosition = 0;   for (let srcPosition = 0; srcPosition < image.data.length;) {     data[destPosition++] = image.data[srcPosition++];     data[destPosition++] = image.data[srcPosition++];     data[destPosition++] = image.data[srcPosition++];     data[destPosition++] = 255;   }    return data; }

Попробую декодировать все полученные изображения:

const qrCodes = []; images.forEach((image) => {   if (image.data) {     const data = extractRGBAData(image);      try {       const code = jsQR(data, image.width, image.height);       console.log(code);       qrCodes.push(code);     } catch (err) {       console.log(err);     }   } });

В результате в консоль браузера выведено 7 сторк — по одной на каждый QR код на странице. Одна из строк содержит URL документа — QR код с ней размещен в верхней правой части документов, она меня не интересует. Остальные 6 строк содержат XML следующего формата (персональные данные удалены):

<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <BarcodeElement xmlns="http://barcodes.pdf.shep.nitec.kz/">   <creationDate>...</creationDate>   <elementData>...</elementData>   <elementNumber>1</elementNumber>   <elementsAmount>6</elementsAmount>   <FavorID>...</FavorID> </BarcodeElement>

Из этого меня интересуют следующие теги:

  • <elementData>...</elementData> — часть данных
  • <elementNumber>1</elementNumber> — индекс текущей части
  • <elementsAmount>6</elementsAmount> — общее количество частей на которые разделены данные

3. Извлечение частей данных

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

const qrCodesBlocks = [];  function addQRCodeBlock(code) {   if (!code || !code.data) {     return;   }    // Получу общее количество частей.   const elementsAmountRegexp = /<elementsAmount>((.|\r|\n)+?)<\/elementsAmount>/;   const elementsAmountResult = elementsAmountRegexp.exec(code.data);   if (!elementsAmountResult || elementsAmountResult.length <= 2) {     return;   }   const elementsAmount = +elementsAmountResult[1];   if (!Number.isSafeInteger(elementsAmount)) {     throw new Error('Не удалось извлечь общее количество частей из тега <elementsAmount>');   }    // При обработке первой части нужно инициализировать массив.   if (qrCodesBlocks.length === 0) {     for (let i = 0; i < elementsAmount; i++) {       qrCodesBlocks.push('');     }   } else {     if (qrCodesBlocks.length !== elementsAmount) {       throw new Error(`В разных QR кодах указано разное общее количество QR кодов: "${qrCodesBlocks.length}" и "${elementsAmount}"`);     }   }    // Получу индекс части.   const elementNumberRegexp = /<elementNumber>((.|\r|\n)+?)<\/elementNumber>/;   const elementNumberResult = elementNumberRegexp.exec(code.data);   if (!elementNumberResult || elementNumberResult.length < 2) {     throw new Error(`В QR коде отсутствует "<elementNumber>"`);   }   const elementNumber = +elementNumberResult[1];   if (!Number.isSafeInteger(elementNumber)) {     throw new Error(`"<elementNumber>" в QR коде не является числом`);   }    // Защита от внештатных ситуаций.   if (elementNumber > elementsAmount) {     throw new Error(`Индекс QR кода "${elementNumber}" больше общего количества QR кодов "${elementsAmount}"`);   }    if (qrCodesBlocks[elementNumber - 1] !== '') {     throw new Error(`Индекс QR кода "${elementNumber}" обнаружен более одного раза`);   }    // Помещу часть в соответствую позицию.   const elementDataRegexp = /<elementData>((.|\r|\n)+?)<\/elementData>/;   const elementDataResult = elementDataRegexp.exec(code.data);   if (!elementDataResult || elementDataResult.length < 2) {     throw new Error('В QR коде отсутствует "<elementData>"');   }   const elementData = elementDataResult[1];    qrCodesBlocks[elementNumber - 1] = elementData; }

Осталось получить части и проверить что распределение прошло успешно.

qrCodes.forEach(addQRCodeBlock);  if (qrCodesBlocks.length === 0) {   throw new Error('Ошибка при извлечении данных из QR кодов: не обнаружено ни одного QR кода с поддерживаемыми данными'); }  const foundBlocks = qrCodesBlocks.filter((block) => !!block); if (qrCodesBlocks.length !== foundBlocks.length) {   throw new Error('Ошибка при извлечении данных из QR кодов: не удалось получить данные всех QR кодов'); }

4. Восстановление данных

Анализ частей данных показал что это ZIP архив разрезанный на части каждая из которых закодирована в Base64.

В первую очередь нужно декодировать части из Base64:

const zippedParts = qrCodesBlocks.map(block => new Uint8Array(gostCrypto.coding.Base64.decode(block)));

Далее соединить их:

const totalLength = zippedParts.reduce((accumulator, part) => accumulator + part.length, 0);  const zippedData = new Uint8Array(totalLength); let zippedDataIndex = 0; zippedParts.forEach((part) => {   zippedData.set(part, zippedDataIndex);   zippedDataIndex += part.length; });

И распаковать архив:

const zip = await JSZip.loadAsync(zippedData, { checkCRC32: true });

В архиве находится единственный файл с именем one, его содержимое меня и интересует:

const file = zip.file('one'); if (!file) {   throw new Error('В архиве отсутствует ожидаемый файл "one"'); }  const recoveredContents = await file.async("string");

5. Подготовка данных к анализу

Восстановленные данные — это XML следующего формата (персональные данные удалены):

<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <p1001Response>     <SystemInfo>         <messageId xsi:nil="true" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"/>         <chainId>...</chainId>         <messageDate>...</messageDate>         `1`         <responseInfoRu>Запрос обработан</responseInfoRu>         <responseInfoKz>Запрос обработан</responseInfoKz>         <digiSign>...</digiSign>     </SystemInfo>     <ResponseData>         <ResponseType>UNJUDGED</ResponseType>         <Person>             <IIN>...</IIN>             <SurName>...</SurName>             <Name>...</Name>             <MiddleName>...</MiddleName>             <BirthDate>...</BirthDate>             <BirthPlace>                 <Country>...</Country>                 <CountryKz>...</CountryKz>                 <District>...</District>                 <DistrictKz>...</DistrictKz>                 <City>...</City>                 <CityKz>...</CityKz>                 <Locality>...</Locality>                 <LocalityKz>...</LocalityKz>             </BirthPlace>         </Person>         <Untried/>         <CheckDate>...</CheckDate>     </ResponseData> </p1001Response>

Из всего перечисленного меня интересует только digiSign — это еще один XML закодированный в Base64. На остальные данные я не обращаю внимания, так как они будут продублированы глубже.

Извлеку и декодирую внутренний XML:

const regexp = /<digiSign>((.|\r|\n)+?)<\/digiSign>/; const regexpResult = regexp.exec(recoveredContents); if (!regexpResult && regexpResult.length !== 2) {   throw new Error('В XML отсутствует "<digiSign>"'); }  const digiSignBytes = gostCrypto.coding.Base64.decode(regexpResult[1]); const xmlDataAndSignature = gostCrypto.coding.Chars.encode(digiSignBytes, 'utf8');

6. Анализ данных

Содержимое внутреннего XML выглядит следующим образом (персональные данные удалены):

<?xml version="1.0" encoding="UTF-8" standalone="no"?> <ResponseData>   <ResponseType>UNJUDGED</ResponseType>   <Person>     <IIN>...</IIN>     <SurName>...</SurName>     <Name>...</Name>     <MiddleName>...</MiddleName>     <BirthDate>...</BirthDate>     <BirthPlace>       <Country>...</Country>       <CountryKz>...</CountryKz>       <District>...</District>       <DistrictKz>...</DistrictKz>       <City>...</City>       <CityKz>...</CityKz>       <Locality>...</Locality>       <LocalityKz>...</LocalityKz>     </BirthPlace>   </Person>   <Untried/>   <CheckDate>...</CheckDate>   <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">     <ds:SignedInfo>       <ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>       <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#gost34310-gost34311"/>       <ds:Reference URI="">         <ds:Transforms>           <ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>           <ds:Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments"/>         </ds:Transforms>         <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#gost34311"/>         <ds:DigestValue>...</ds:DigestValue>       </ds:Reference>     </ds:SignedInfo>     <ds:SignatureValue>...</ds:SignatureValue>     <ds:KeyInfo>       <ds:X509Data>         <ds:X509Certificate>...</ds:X509Certificate>       </ds:X509Data>     </ds:KeyInfo>   </ds:Signature> </ResponseData>

Это XML подпись с внедренными данными в которых, судя по всему, указано что судимостей у субъекта нет <ResponseType>UNJUDGED</ResponseType>, а так же приведены данные для идентификации субъекта в теге <Person>...</Person>.

7. Проверка целостности подписанных данных

Проверка целостности данных будет заключаться в сравнении приведенного в XML подписи значения хеша с вычисленным.

Создание объекта XML документа:

const xml = XmlDSigJs.Parse(xmlDataAndSignature);

Значение хеша приведено в теге <ds:DigestValue>...</ds:DigestValue>:

const xmlSignatures = XmlDSigJs.Select(xml, "//*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']"); if (xmlSignatures.length === 0) {   throw new Error(`В распакованных данных отсутствует цифровая подпись (тег "<Signature>"): "${xmlDataAndSignature}"`); } if (xmlSignatures.length > 1) {   throw new Error(`В распакованных данных присутствует несколько цифровых подписей (тег "<Signature>"): "${xmlDataAndSignature}"`); }  const hashElementsInSignature = XmlDSigJs.Select(xmlSignatures[0], "//*[local-name(.)='DigestValue']"); if (hashElementsInSignature.length === 0) {   throw new Error(`В XML подписи отсутствует хеш (тег "<DigestValue>"): "${xmlDataAndSignature}"`); } if (hashElementsInSignature.length > 1) {   throw new Error(`В XML подписи присутствует несколько хешей (тег "<DigestValue>"): "${xmlDataAndSignature}"`); } const hashInSignature = hashElementsInSignature[0].textContent;

Данные необходимо подготовить к хешированию — выполнить над ними трансформации приведенные в теге <ds:Transforms>...</ds:Transforms> XML подписи:

const xmlDsigEnvelopedSignatureTransform = new XmlDSigJs.XmlDsigEnvelopedSignatureTransform(); xmlDsigEnvelopedSignatureTransform.LoadInnerXml(xml.documentElement); xmlDsigEnvelopedSignatureTransform.GetOutput();  const xmlDsigC14NWithCommentsTransform = new XmlDSigJs.XmlDsigC14NWithCommentsTransform(); xmlDsigC14NWithCommentsTransform.LoadInnerXml(xml.documentElement); const signedDataXML = xmlDsigC14NWithCommentsTransform.GetOutput();  const dataToHash = gostCrypto.coding.Chars.decode(signedDataXML, 'utf8');

В подписи указан алгоритм хеширования "http://www.w3.org/2001/04/xmldsig-more#gost34311", это ГОСТ 34.311-95 что аналогично GOST R 34.11-94 в библиотеке gostCrypto. В РК совместно с ним обычно используют набор параметров D-TEST.

Вычисление значения хеша:

const hashBytes = await gostCrypto.subtle.digest({name: 'GOST R 34.11-94', version: 1994, sBox: 'D-TEST'}, dataToHash); const signedDataXMLHash = gostCrypto.coding.Base64.encode(hashBytes);

Проверка целостности данных в моем документе прошла корректно:

if (signedDataXMLHash !== hashInSignature) {   throw new Error(`Хеш вычисленный из данных XML документа "${signedDataXMLHash}" не соответствует значению в подписи "${hashInSignature}"`); }

Заключение

Эксперимент завершился успешно, поставленная цель достигнута. Но нужно упомянуть о том, что без проверки цифровой подписи говорить о целостности данных несколько лукаво — нет уверенности в том, что значение хеша данных в XML подписи не было изменено.

Еще одним нюансом оказалось то, что разные типы документов электронного правительства РК имеют разную структуру: в некоторых случаях восстановленный документ сразу является XML подписью, в других в digiSign подпись не закодирована в Base64, а вместо этого представлена в HTML кодировке, бывают и другие варианты. В связи с чем приведенный выше алгоритм не является универсальным и требует доработки под разные типы документов.

Полезные ссылки:

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