Спустя десятки лет после появления землян на планете Плюк на место КЦ пришёл КУ-АР в качестве самого ценного ресурса для чатлан и пацаков. Желающие приобрести себе в будущем малиновые штаны представители двух народов нашли способ подделки этого ценного средства, вследствие чего понадобилось внедрение способа его проверки на подлинность.
В этой статье я расскажу о том, как я разрабатывал Android-приложение для сканирования и верификации сертификатов вакцинации, а также о том, что из этого в итоге вышло.

Всё начинается с чистки зубов
Кому-то приходят идеи ночью, во сне, кто-то получает возможность для погружения в «поток» во время упорной работы, а я вот испытываю бесконечный поток мыслей, в котором мелькают различные идеи, во время чистки зубов.
Так и на этот раз, в октябре, во время чистки зубов, на фоне массовой публикации постановлений с ковидными ограничениями мне в голову пришла мысль о потенциальной возможности обхода проверки QR-кодов за счёт использования фишинговых сайтов с поддельными сертификатами вакцинации.
Тогда я сразу понял, что я не один такой умный (что позже подтвердилось), и что потенциально это может стать серьёзной проблемой для полноценной реализации антиковидных мер. Текущую ситуацию с фишинговыми сайтами, являющимися клонами страниц с данными сертификатов вакцинации я описал в другой статье ранее.
На тот момент я не слышал о существовании других приложений в России, созданных именно под сертификаты вакцинации, но при этом решил специально не проверять эту информацию (А что из этого потом вышло вы узнаете позже), поэтому спустя два дня я приступил к разработке собственного решения.
Принцип работы приложения
Концепция функционирования приложения является достаточно простой.
На вход должно поступить содержимое QR-кода после его сканирования, после чего это содержимое проверяется на факт того, является ли оно ссылкой. В случае, если QR-код содержит что-то кроме ссылки, то приложение должно вывести ошибку. Если всё же приложение содержит ссылку, то происходит проверка домена этой ссылки, а также подкаталогов и имён страницы, и если ссылка не соответствует заданным условиям, то приложение также выводит ошибку.
В случае же, если ссылка валидная, то далее идёт запрос данных сертификата, их вывод на экран, а также проверка на повторное использование этого сертификата, после чего данные сохраняются в историю сканирования.
В историю сканирования также сохраняются и ошибки вместе с содержимым QR-кода для возможности оценки статистики использования QR-кодов с курицей по скидке.

Процесс сканирования
Публичный репозиторий с кодом проекта доступен на GitHub.
Для сканирования QR-кодов я использовал библиотеку, основанную на библиотеке ZXing.
За сканирование и декодирование QR-кода в приложении отвечает процедура codeScannerProc(), в которой используется метод подключённой библиотеки для декодирования содержимого QR-кода onDecoded():
public class MainActivity extends AppCompatActivity implements View.OnClickListener { /* code */ private void codeScannerProc(){ codeScanner.setDecodeCallback(new DecodeCallback() { @Override public void onDecoded(@NonNull final Result result) { runOnUiThread(new Runnable() { @Override public void run() { checkContent(result.getText()); } }); } }); codeScannerView.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { codeScanner.startPreview(); } }); } /* code */ }
Внутри метода onDecoded(), в который передаётся содержимое QR-кода находится переопределённый метод run(), который вызывает метод проверки данных, содержавшихся в QR-коде.
Проверка содержимого QR-кода
Для того, чтобы отбросить любые данные кроме ссылки на сертификат вакцинации, используется метод checkContent(), в который передается строка с содержимым QR-кода:
public class MainActivity extends AppCompatActivity implements View.OnClickListener { /* code */ private void checkContent(String str){ Date currentTime = Calendar.getInstance().getTime(); String scanTime = String.valueOf(currentTime); scanTime = scanTime.replace(" ", "\\"); if (!quickResponseCodeURL.isURL(str)) { historyFileInputOutput.writeInvalidQrToFile(1, str, scanTime); showNotSuccessScanResultAlertDialog(SCAN_RESULT_NOT_URL); return; } if (!quickResponseCodeURL.isValidURL(str)) { historyFileInputOutput.writeInvalidQrToFile(2, str, scanTime); showNotSuccessScanResultAlertDialog(SCAN_RESULT_INVALID_URL); return; } str = quickResponseCodeURL.replaceSpaces(str); startCertificateActivity(str); } /* code */ }
В начале происходит проверка на факт того, что содержимое вообще является ссылкой. Для этого используется метод isURL() класса QuickResponseCodeURL.
Метод isURL(), как и последующие методы проверки содержимого QR-кода использует регулярное выражение для возвращения результата в виде boolean-значения.
Для проверки на факт соответствия ссылке используется шаблон регулярного выражения в виде экземпляра класса Pattern – urlPattern (для шаблона ссылки используется стандарт RFC 3986). При помощи класса Matcher и метода matches() мы получаем результат «true» в том случае, если содержимое соответствует шаблону ссылки, и, соответственно false – во всех других случаях.
public class QuickResponseCodeURL { // Pattern for recognizing a URL, based off RFC 3986 private static final Pattern urlPattern = Pattern.compile( "(?:^|[\\W])((ht|f)tp(s?):\\/\\/|www\\.)" + "(([\\w\\-]+\\.){1,}?([\\w\\-.~]+\\/?)*" + "[\\p{Alnum}.,%_=?&#\\-+()\\[\\]\\*$~@!:/{};' ]*)", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL); /* code */ // function to check if qr contains url public boolean isURL(String str){ if (urlPattern.matcher(str).matches()) return true; else return false; } /* code */ }
В ситуации же, если строка содержимого совпадает с шаблоном ссылки, происходит проверка на соответствие единственно верному доменному имени, а также на шаблон пути, который содержится в ссылке. Для этого используется метод isValidURL().
В процессе изучения предметной области был сделан вывод о том, что правильные ссылки должны содержать домен «gosuslugi.ru», а также один из возможных путей:
-
/covid-cert/verify/**************** (где * – это цифры номера сертификата);\
-
/vaccine/cert/verify//************************************ (где * – это знаки некого хэш-кода);
-
/covid-cert/status/************************************ (где * – это знаки некого хэш-кода).
Первый и второй тип путей обычно используются для сертификатов вакцинации, а последний – для временных сертификатов.
Аналогично проверке содержимого на соответствие шаблону ссылки происходит проверка на валидность ссылки при помощи экземпляров класса Pattern validUrlDomain и urlPathPattern:
public class QuickResponseCodeURL { /* code */ // Pattern for valid url path // example: /covid-cert/verify/****************, where ***************** - certificate id // example: /covid-cert/status/************************************, where ************************************ - hash sum // example: /vaccine/cert/verify/************************************, where ************************************ - hash sum private static final Pattern urlPathPattern = Pattern.compile( "^/[\b(covid\\-cert)|(vaccine)\b/]+/[\b(verify|status|cert/verify)\b/]+/[^/]+[a-zA-Z0-9]$" ); // Pattern for valid url domain private static final Pattern validUrlDomain = Pattern.compile( "^www.gosuslugi.ru$" ); /* code */ // function check if url is valid (has valid domain and valid path) public boolean isValidURL(String str){ Uri quickResponseCodeURI = Uri.parse(str); String domainName = quickResponseCodeURI.getHost(); String path = quickResponseCodeURI.getPath(); if (validUrlDomain.matcher(domainName).matches() && urlPathPattern.matcher(path).matches()) return true; return false; } /* code */ }
В ситуации, когда содержимое QR-кода не является ссылкой или содержит невалидную ссылку, на экран выводится соответствующее диалоговое окно, а также происходит сохранение результата сканирования вместе с датой и временем сканирования для осуществления возможности ведения статистики.

Извлечение данных сертификата
В случае, когда ссылка валидная, открывается новый экран CertificateActivity для извлечения данных сертификата.
Для получения данных используется внутренний класс FetchJsonData, который является наследником класса AsyncTask, что необходимо для выполнения GET-запроса в фоновом режиме при помощи переопределённого метода doInBackGround() и метода fetch().
Данные сертификата (если он существует) содержатся в виде JSON-объекта.
JSON (JavaScript Object Notation)-объект – это текстовый формат обмена данными между сервером и клиентом.
Для того, чтобы получить JSON-объект при выполнении GET-запроса, необходимо знать ссылку, по которой осуществляется доступ к этим текстовым данным.
В процессе изучения предметной области было выяснено, что структура ссылки JSON-объекта зависит от типа ссылки сертификата (которых, как указано выше, найдено 3 типа). Поэтому, последующего запроса происходит преобразование ссылки посредством её перестройки:
public class CertificateActivity extends AppCompatActivity implements View.OnClickListener { /* code */ public void fetch(){ // 1 тип //https://www.gosuslugi.ru/covid-cert/verify/****************?lang=ru&ck=******************************** - url //https://www.gosuslugi.ru/api/covid-cert/v3/cert/check/****************?lang=ru&ck=******************************** - json of url //https://www.gosuslugi.ru/covid-cert/verify/****************?lang=ru&ck=******************************** - url //https://www.gosuslugi.ru/api/covid-cert/v3/cert/check/****************?lang=ru&ck=******************************** - json of ilness // 2 тип //https://www.gosuslugi.ru/vaccine/cert/verify/************************************ - url //https://www.gosuslugi.ru/api/vaccine/v1/cert/verify/************************************ - json of vacc from paper // 3 тип //https://www.gosuslugi.ru/covid-cert/status/************************************?lang=ru - url //https://www.gosuslugi.ru/api/covid-cert/v2/cert/status/************************************?lang=ru - json String[] urlElementsArray = websiteUrl.split("/"); ArrayList<String> ar = new ArrayList<>(Arrays.asList(urlElementsArray)); ar.remove(""); String jsonUrl = ""; if (websiteUrl.contains("vaccine")) { jsonUrl = ar.get(0) + "//" + ar.get(1) + "/api/" + ar.get(2) + "/v1/" + ar.get(3) + "/" + ar.get(4) + "/" + ar.get(5); }else if (websiteUrl.contains("covid-cert") && !websiteUrl.contains("status")) { jsonUrl = ar.get(0) + "//" + ar.get(1) + "/api/" + ar.get(2) + "/v3/cert/check/" + ar.get(4); }else if (websiteUrl.contains("covid-cert") && websiteUrl.contains("status")){ jsonUrl = ar.get(0) + "//" + ar.get(1) + "/api/" + ar.get(2) + "/v2/cert/status/" + ar.get(4); } /* code */ } /* code */ }
Затем, при помощи экземпляра класса HttpURLConnection осуществляется соединение по адресу преобразованной ссылки для последующей возможности считать данные входного потока, используя класс InputStream.
Данные JSON-объекта в виде строки преобразуются в экземпляр класса JSONObject для более удобной работы с последующим извлечением данных.
public class CertificateActivity extends AppCompatActivity implements View.OnClickListener { /* code */ public void fetch(){ /* code */ URL url = new URL(jsonUrl); HttpURLConnection httpURLConnection = (HttpURLConnection) url.openConnection(); // save time value when http connection starts httpStartTime = Calendar.getInstance().getTime(); InputStream inputStream = httpURLConnection.getInputStream(); BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream)); String line = ""; while((line = bufferedReader.readLine()) != null){ data = data + line; } if (!data.isEmpty()){ jsonObject = new JSONObject(data); jsonSucceeed = true; } /* code */ } /* code */ }
Чтобы извлечь конкретные данные сертификата используется метод parseJson() класса ParseCertificateJson.
Данные и их расположение внутри объекта JSON отличается в зависимости от типа сертификата и типа ссылки, поэтому в классе ParseCertificateJson имеется несколько методов для извлечения информации о владельце сертификата. В качестве примера для одного из типов сертификата приведён фрагмент кода (для других типов желающие могут посмотреть исходный код на странице проекта):
public class ParseCertificateJson { /* code */ private void parseJsonWithoutItems(){ try { certificateId = jsonObject.getString("unrz"); fio = jsonObject.getString("fio"); enFio = jsonObject.getString("enFio"); birthDate = jsonObject.getString("birthdate"); passport = jsonObject.getString("doc"); enPassport = jsonObject.getString("enDoc"); status = jsonObject.getString("status"); expiredAt = jsonObject.getString("expiredAt"); stuff = jsonObject.getString("stuff"); } catch (JSONException e) { e.printStackTrace(); } } /* code */ }

После получения информации о сертификате данные выводятся на экран примерно в том же формате, что и на официальном государственном ресурсе «Госуслуги».

Для избежания возможности использования одного и того же сертификата несколькими людьми (особенно подряд) осуществляется проверка на переиспользование сертификата при помощи метода checkPotentialCertificateReuse(), который запрашивает историю сканирования, преобразует её в структуру данных ArrayList и производит в цикле поиск по элементам списка сертификата с аналогичным номером.
В случае, если сертификат действительно повторно используется, на экран выводится соответствующее диалоговое окно с рекомендованными инструкциями по дальнейшим действиям.

Хранение сканированных данных
Чтобы фиксировать повторное использование одного и того же сертификата или иметь возможность сбора статистики по использованию невалидных ссылок или данных, а также разных типов сертификатов, в приложении сохраняется история сканирования.
Хранение в файле
История сканирования хранится в файле в закрытом режиме, который позволяет сделать его недоступным пользователю для прямого взаимодействия без root-прав.
Для работы с хранением истории сканирования используются классы HistoryFileInputOutput и HistoryFileParser. В первом определены методы, осуществляющие операции с файлом (создание, запись, чтение и очистка), а во втором – методы, производящие преобразование хранящихся в файле данных в ArrayList с экземплярами класса QuickResponseCodeHistoryItem (для поиска возможного переиспользования и последующей распечатки истории сканирования).
Данные хранятся в файле в следующем формате:
Для невалидных данных и ссылок:
[qrCodeType][content][currentTime]
-
qrCodeType – тип QR-кода;
-
content – содержимое QR-кода;
-
currentTime – дата и время сканирования.
Для сертификатов:
[qrCodeType][certificateReuse][type][title][status][certificateId][expiredAt][validFrom][isBeforeValidFrom][fio][enFio][recoveryDate][passport][enPassport][birthDate][currentTime]
-
qrCodeType – тип QR-кода;
-
certificateReuse – информация о переиспользовании сертификата (по умолчанию имеет значение «false»);
-
type – тип сертификата (сертификат вакцинации, сертификат переболевшего, временный сертификат вакцинации или результат ПЦР-теста);
-
title – название сертификата;
-
status – статус действительности сертификата;
-
expiredAt – дата истечения срока действия сертификата;
-
validFrom – дата начала действия сертификата (для временных сертификатов);
-
isBeforeValidFrom – статус начала действия сертификата (для временных сертификатов);
-
fio – ФИО владельца сертификата;
-
enFio – ФИО владельца сертификата на латинице;
-
recoveryDate – дата выздоровления (для сертификатов переболевших);
-
passport – данные паспорта владельца сертификата;
-
enPassport – номер загранпаспорта владельца сертификата;
-
birthdate – дата рождения владельца сертификата;
-
currentTime – дата и время сканирования.
Пример фрагмента данных, хранящихся в файле (персональные данные закрашены):

Значения qrCodeType в зависимости от типа QR-кода:
1 – для невалидных данных;
2 – для невалидных ссылок;
3 – для сертификатов, о которых найдена информация;
4 – для сертификатов, информация о которых не найдена.
Если QR-код не содержит определённых данных, то их значение равно «0».
Хранение информации в файле не является оптимальным решением, но вполне удовлетворяет на этапе Pet-проекта без создания системы авторизации и отправки данных в облако.
История сканирования
Благодаря сохранению в файле информации о дате и времени сканирования приложение позволяет вывести достаточно подробную историю сканирования при помощи классов QuickResponseCodeHistoryActivity и QuickResponseCodeHistoryRecViewAdapter.

История сканирования отображает статус QR-кода при помощи прокручивающегося текста и в виде цветного изображения слева:
-
Зелёным выделяются подтверждённые сертификаты (UPD: во время написания статьи была добалвена возможность обработки QR-кодов ПЦР-тестов, поэтому теперь зелёным выделяются ещё и отрицательные ПЦР-тесты);
-
Жёлтым выделяются повторно использующиеся сертификаты;
-
Красным выделяются невалидные ссылки, данные, а также сертификаты, информация о которых не найдена (UPD: во время написания статьи была добалвена возможность обработки QR-кодов ПЦР-тестов, поэтому теперь красным выделяются ещё и положительные ПЦР-тесты).
Также при желании можно развернуть информацию о конкретном QR-коде и посмотреть содержащиеся в нём данные.
Как не нужно начинать разработку проекта
Как было указанно во введении, после того, как мне в голову пришла идея, я не стал тщательно искать информацию о существовании подобного решения на российском рынке мобильных приложений, так как изначально посчитал, что если я об этом не слышал, то и вряд ли такое приложение существует.
В принципе я оказался прав, так как отдельного приложения-сканера для верификации QR-кодов действительно не существует в России на момент написания этого раздела статьи. Но на вторые сутки разработки я узнал о том, что есть встроенный сканер в приложении «Госуслуги СТОП Коронавирус», что помогло осознать достаточно серьёзную ошибку в подготовке к началу разработки.
Разработку демоверсии приложения я всё-таки закончил, хотя и работал впоследствии с куда меньшим энтузиазмом, чем в самом начале. Но всё время я уже не мог отделаться от мысли о том, что вместо этого я мог бы заниматься чем-то более полезным несмотря на то, что фактически в процессе работы я всё равно получал опыт внедрения некоторых новых для себя элементов интерфейса.
Из этой истории я вынес для себя важный урок в правильном подходе к разработке проектов, который заключается не только в тщательном анализе предметной области перед процессом написания кода, но также и в обязательном поиске информации о существовании похожих решений.
ссылка на оригинал статьи https://habr.com/ru/post/646243/
Добавить комментарий