Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Спустя десятки лет после появления землян на планете Плюк на место КЦ пришёл КУ-АР в качестве самого ценного ресурса для чатлан и пацаков. Желающие приобрести себе в будущем малиновые штаны представители двух народов нашли способ подделки этого ценного средства, вследствие чего понадобилось внедрение способа его проверки на подлинность.
В этой статье я расскажу о том, как я разрабатывал 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-кодов действительно не существует в России на момент написания этого раздела статьи. Но на вторые сутки разработки я узнал о том, что есть встроенный сканер в приложении «Госуслуги СТОП Коронавирус», что помогло осознать достаточно серьёзную ошибку в подготовке к началу разработки.
Разработку демоверсии приложения я всё-таки закончил, хотя и работал впоследствии с куда меньшим энтузиазмом, чем в самом начале. Но всё время я уже не мог отделаться от мысли о том, что вместо этого я мог бы заниматься чем-то более полезным несмотря на то, что фактически в процессе работы я всё равно получал опыт внедрения некоторых новых для себя элементов интерфейса.
Из этой истории я вынес для себя важный урок в правильном подходе к разработке проектов, который заключается не только в тщательном анализе предметной области перед процессом написания кода, но также и в обязательном поиске информации о существовании похожих решений.