Как стать автором
Обновить

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

Время на прочтение12 мин
Количество просмотров6.6K

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

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

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

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

Теги:
Хабы:
-10
Комментарии15

Публикации

Изменить настройки темы

Истории

Работа

Java разработчик
356 вакансий

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн