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

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

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

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

Комментарии (15)


  1. upagge
    18.01.2022 13:16
    -1

    Надеюсь разрабатывал ты лучше, чем оформлял статью :)


    1. Girrafeec Автор
      18.01.2022 13:21
      +2

      На данный момент оформление исправлено.


  1. vilgeforce
    18.01.2022 13:26

    А где у вас проверка хоста, что-то не вижу... И да, кто-то вам позволял хранить данные "клиентов"?


    1. hipachka
      18.01.2022 13:31
      +1

      Вроде тут есть:

      https://github.com/Girrafeec/CC-QR-Scanner/blob/main/app/src/main/java/com/girrafeecstud/ccqrscanner/QuickResponseCodeURL.java

      Начиная с 16 строки
          // 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$"
          );
      
          // function to check if qr contains url
          public boolean isURL(String str){
      
              if (urlPattern.matcher(str).matches())
                  return true;
              else
                  return false;
      
          }


      1. navferty
        18.01.2022 13:33
        +1

        То есть проверки нет. Например, я регистрирую домен example.club, на нём делаю поддомен www.gosusugi.ru.example.club - и оно успешно проходит проверку


        1. hipachka
          18.01.2022 13:38
          +1

          имхо www.gosusugi.ru.example.club не пройдет проверку


          1. navferty
            18.01.2022 13:43
            +4

            Да, ваша правда, не увидел что там $ и ^

            А вот например такой wwwxgosuslugi.ru , полагаю, пройдет - ведь точки не экранированы, и означают любой символ (хотя я не спец в джаве, может быть там как-то по-другому работает)

            По идее должно быть так:

            ^www\.gosuslugi\.ru$


            1. WraithOW
              18.01.2022 13:51
              +5

              Правильный вопрос — зачем там вообще регулярка, если нужно просто две строки сравнить


          1. vilgeforce
            18.01.2022 13:46
            +1

            Судя по всему, автор не хочет признавать валидным и сайт без www...


            1. navferty
              18.01.2022 13:58
              +2

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


  1. WraithOW
    18.01.2022 14:36
    +4

    Всё грустно.

    1. Пилить строковые ресурсы в коде — плохо
    2. Ходить в файловую систему из UI-потока — очень, очень плохо
    3. Ходить в сеть через HttpUrlConnection в 2022 — выглядит как особая форма мазохизма
    4. Открыто хранить данные — идея сомнительная, особенно в простом файле на диске
    5. Чем костылить хранение табличных данных в текстовом файле — почему не использовать базу
    6. Волшебные константы, волшебные константы повсюду
    7. Всё навалено в кучу, ни архитектуры, ничего
    8. Не умеете пользоваться switch
    9. При проверке на переиспользование в упор не вижу проверку на интервал. Даже если совпадение было месяц назад — всё равно будет «был использован сегодня»


  1. Tzimie
    18.01.2022 20:48

    Главное, помогать сегрегации - очень плохо


    1. souls_arch
      18.01.2022 21:37

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


  1. souls_arch
    18.01.2022 21:29
    +2

    1) qr-код не является антиковидной мерой, и любой здравомыслящий человек должен это понимать.

    2) если глупый раб с со стокгольмским синдромом сам решил свой металлический механический ошейник поменять на электронный, который надежней, - пусть оставит его реализацию для себя и своего хозяина. Или ложится в палату, где чатлане и пацаки - это норма. В массы рабство нести не надо, даже если очень хочется выслужиться перед господином! А то ведь служить можно как Богу, так и сатане


  1. iliar
    18.01.2022 22:06

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

    В первую очередь в глаза бросился бардак с самими сертификатами. Три разных формата ссылок в сертификатах (и не факт, что это полный набор, там где три там и четыре и пять, просто возможно они ещё не попадались). Не ужели утвердить единый формат QR кодов это так сложно? Вот по одному только этому факту можно понять какой бардак творится среди разработчиков сервиса. Выглядит так, как будто бы там несколько комманд разработчиков, которые между собой ни как не взаимодействуют. Из за чего каждый делает свой костылесипед.