Разработка Android-приложения на Java для верификации QR-кодов сертификатов вакцинации

от автора

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

В этой статье я расскажу о том, как я разрабатывал 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-значения.

Для проверки на факт соответствия ссылке используется шаблон регулярного выражения в виде экземпляра класса PatternurlPattern (для шаблона ссылки используется стандарт 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», а также один из возможных путей:

  1. /covid-cert/verify/**************** (где * – это цифры номера сертификата);\

  2. /vaccine/cert/verify//************************************ (где * – это знаки некого хэш-кода);

  3. /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 */    }
Пример JSON-объекта сертификата вакцинации
Пример JSON-объекта сертификата вакцинации

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

Данные сертификатов
Данные сертификатов

Для избежания возможности использования одного и того же сертификата несколькими людьми (особенно подряд) осуществляется проверка на переиспользование сертификата при помощи метода 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/


Комментарии

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

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