Добавить в корзинуПозвонить
Найти в Дзене

Реализация шифрованного (RSA) канала передачи данных между Android и Linux (Raspberry Pi)

Код тут https://disk.yandex.ru/d/4by5qijBXl168A Постановка задачи Необходимо наладить канал передачи данных между смартфоном на Android (у меня 8.0) и Raspberry Pi 3B (Raspberry Pi OS). Передаваемые данные необходимо шифровать в обе стороны. Решено было использовать алгоритм шифрования RSA. Версия Android Studio 2021.1.1 Patch 2. Мною были приняты следующие ограничения: Имена файлов ключей: В RPi ключи шифрования хранить буду в папке с программой, в Android ключи шифрования хранить буду в закрытой папке приложения. Сразу хочу извиниться за лишние операторы и недоработки в коде. Решение для сервера (RPi) Для решения задачи создаётся 3 потока: Работа с сигналами. В функции main (файл scr_lnk.c) отлавливаем нажатие комбинации клавиш «CTRL+C» для выхода из приложения: В потоке 1 (файл network_lnk.c) отлавливаем сигнал SIGPIPE — отключение клиента (разрыв соединения): Общий принцип работы. В функции main(файл scr_lnk.c): Примечание. Ключи для openssl (RPi) должны иметь префиксы и суффиксы

Код тут https://disk.yandex.ru/d/4by5qijBXl168A

Постановка задачи

Необходимо наладить канал передачи данных между смартфоном на Android (у меня 8.0) и Raspberry Pi 3B (Raspberry Pi OS). Передаваемые данные необходимо шифровать в обе стороны.

Решено было использовать алгоритм шифрования RSA.

Версия Android Studio 2021.1.1 Patch 2.

Мною были приняты следующие ограничения:

  • процесс обмена ключами для упрощения свести к ручному копированию;
  • авторизации не предусмотрено;
  • на Linux (RPi) использую библиотеку openssl (версия 1.1.1);
  • по рекомендации man openssl выбираем заполнение (padding) RSA_PKCS1_OAEP_PADDING;
  • соответственно на Java (Android) будет использована трансформация при шифровании «RSA/ECB/OAEPWithSHA-1AndMGF1Padding»;
  • длину ключа принял 1024 бит (128 байт), соответственно, учитывая заполнение 42 байта, максимальная длина передаваемых посылок не более 86 байт;
  • сервер будет на RPi, а клиент на Android;
  • передачу данных на стороне Android реализуем из MainActivity с помощью объекта Thread, соответственно при поворотах экрана или смене активности будет осуществляться переподключение к серверу — это решение 1;
  • передачу данных на стороне Android реализуем из сервиса (отключение сервиса явное, либо по таймауту сообщений от MainActivity) — это решение 2.

Имена файлов ключей:

  • RSAkey.pub — для открытого ключа;
  • RSAkey.prv — для закрытого ключа.

В RPi ключи шифрования хранить буду в папке с программой, в Android ключи шифрования хранить буду в закрытой папке приложения.

Сразу хочу извиниться за лишние операторы и недоработки в коде.

Решение для сервера (RPi)

Для решения задачи создаётся 3 потока:

  • Поток 1: обмен данными со смартфоном по WiFi, файл «network_lnk.c», функция потока «void * network_lnk(void * arg)»;
  • Поток 2: вывод на печать для просмотра результата обмена, файл «data_prn.c», функция «void * data_print(void * arg)»;
  • Поток 3: шифрование/расшифровка передаваемых/принимаемых данных, файл «enc_dec.c», функция «void * enc_dec(void * arg)».

Работа с сигналами.

В функции main (файл scr_lnk.c) отлавливаем нажатие комбинации клавиш «CTRL+C» для выхода из приложения:

  • переменная volatile sig_atomic_t shtdwn; является флагом выключения программы (0 - работаем, 1 - выключить);
  • функция void exit_signal_handler(int signal); осуществляет обработку сигнала SIGINT - выхода из программы (Ctrl+C) (просто присваиваем shtdwn = 1);

В потоке 1 (файл network_lnk.c) отлавливаем сигнал SIGPIPE — отключение клиента (разрыв соединения):

  • переменная volatile sig_atomic_t connected; является флагом установления соединения с клиентом (0 - нет подключения, 1 - подключение установлено);
  • функция void sigpipe_signal_handler(int signal); осуществляет обработку сигнала SIGPIPE (просто устанавливаем connected = 0).

Общий принцип работы.

В функции main(файл scr_lnk.c):

  • инициализируем переменные;
  • выделяем память под буфера (принятый зашифрованный, принятый не зашифрованный, отправляемый зашифрованный, отправляемый не зашифрованный), длину буферов лучше сделать равной длине ключа в байтах;
  • включаем обработку сигнала SIGINT;
  • получаем ключи шифрования RSA, для этого служит функция: RSA *importKEYfromFS(int type, const char *fname), которой передаётся тип ключа (1 - закрытый, 2 - открытый) в функции мы открываем файл FILE *keyFile с нужным ключом и считываем его в переменную
    RSA *key функцией PEM_read_RSA_PUBKEY(keyFile, NULL, NULL, NULL), либо PEM_read_RSAPrivateKey(keyFile, NULL, NULL, NULL) в зависимости от типа ключа;

Примечание. Ключи для openssl (RPi) должны иметь префиксы и суффиксы:

  • «-----BEGIN PUBLIC KEY-----\n»;
  • «-----END PUBLIC KEY-----»;
  • «-----BEGIN PRIVATE KEY-----\n»;
  • «-----END PRIVATE KEY-----».

Ключи для Java (Android) этих префиксов иметь не должны.

  • получаем свой IP адрес (функция
    struct in_addr get_myip(char *iftype) (iftype = "eth0", "wlan0" и т.д.));
  • создаём сервер (create_server(myip, lstnport));
  • подготавливаем атрибуты потоков и запускаем потоки 1, 2, 3;
  • далее начинает работу цикл, который с периодом SHUTDOWNTIMEOUT осуществляет проверку сигнала на выключение программы (shtdwn == 0);
  • как только приходит сигнал на останов программы (shtdwn == 1) осуществляется останов потоков (если они ещё не остановились сами) и освобождение ресурсов.

Поток 1. В функции network_lnk (файл network_lnk.c):

  • инициализируем переменные;
  • выделяем память под буфера (принятый запрос, отправляемый ответ — оба зашифрованные!!), длину буферов надо сделать равной длине ключа в байтах;
  • включаем обработку сигнала SIGPIPE;
  • далее начинает работу цикл, который заканчивает работу при shtdwn == 1, в цикле:
  • принимаем входящее подключение, говорим что подключение установлено (connected = 1);
  • в цикле пока есть подключение выполняем следующее:
  1. если данные обработаны (расшифровано предыдущее входящее сообщение, сформирован и зашифрован ответ), то
  2. копируем в выходной буфер зашифрованный запрос приложению на Android используя семафоры;
  3. отправляем зашифрованные данные в смартфон (длина отправляемых данных равна длине ключей шифрования);
  4. принимаем зашифрованный ответ от смартфона на Android (длина принимаемых данных равна длине ключей шифрования);
  5. копируем во входной буфер зашифрованный запрос от приложения на Android используя семафоры;
  • по приходу команды на завершение (shtdwn == 1) освобождаем ресурсы.

Поток 3. В функции enc_dec(файл enc_dec.c):

  • инициализируем переменные;
  • выделяем память под буфера (принятый зашифрованный, принятый не зашифрованный, отправляемый зашифрованный, отправляемый не зашифрованный), длину буферов надо сделать равной длине ключа в байтах;
  • далее начинает работу цикл, который заканчивает работу при
    shtdwn == 1, в цикле:
  • если принята свежая порция данных (datarcvd == 1);
  • копируем во входной буфер зашифрованное сообщение от приложения на Android используя семафоры;
  • расшифровываем данные с помощью
    RSA_private_decrypt(key_len, rcv_enc_buf, rcv_dec_buf, prvKey, RSA_PKCS1_OAEP_PADDING), обращаю внимание на первый параметр в функции - это длина ключа!!;
  • формируем ответ;
  • не забываем выгрузить в глобальные буфера расшифрованные сообщения с помощью семафоров;
  • шифруем сообщение приложению с помощью
    R
    SA_public_encrypt(data_len + 1, send_dec_buf, send_enc_buf, pubKey, RSA_PKCS1_OAEP_PADDING), обращаю внимание на первый параметр в функцию - это длина не зашифрованных данных!!;
  • отправляем в общий доступ зашифрованное сообщение для отправки с помощью семафоров;
  • обработка данных завершена;
  • по приходу команды на завершение (shtdwn == 1) освобождаем ресурсы.

Поток 2. В функции data_print(файл data_prn.c):

  • инициализируем переменные;
  • выделяем память под буфера (принятый и отправляемый не зашифрованные), длину буферов лучше сделать равной длине ключа в байтах;
  • далее начинает работу цикл, который заканчивает работу при
    shtdwn == 1, в цикле:
  1. если данные обработаны (расшифровано входящее сообщение, сформирован ответ), то:
  2. копируем во внутренние буфера потока расшифрованные сообщения используя семафоры;
  3. выводим на печать результат;
  • по приходу команды на завершение (shtdwn== 1) освобождаем ресурсы.

Решение 1. Обменом данными с сервером заведует основной поток приложения

Схема взаимодействия приложений для случая управления подключением из MainActivity
Схема взаимодействия приложений для случая управления подключением из MainActivity

Решение для клиента (Android)

Для решения задачи создаётся 2 вспомогательных класса:

  • TCPClient: обмен данными со смартфоном по WiFi, файл «TCPClient.java»;
  • RSAEncrypt: операции с ключами (генерация, загрузка, выгрузка, импорт (в формате openssl), экспорт (из формата openssl)), шифрование, расшифровка данных, файл «RSAEncrypt.java».

Манифест приложения

Должны быть запрошены следующие разрешения:

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.INTERNET" />

Главное окно приложения (MainActivity).

В главном окне расположены:

  • строка ввода данных для отправки (переменная mEditTxtSS);
  • строка принятых данных (переменная mTxtViewRS), сюда же выводятся сообщения об ошибках связи;
  • кнопка «Подключиться» (сразу после подключения сервер начинает обмен данными);
  • строка с отображением состояния подключения (переменная mLinkStatus);
  • группа кнопок управления генерацией, выгрузкой (экспорт) и загрузкой (импорт) ключей;
  • строка с отображением состояния ключей шифрования (переменная mTxtViewKeyStatus);
  • кнопка проверки шифрования/расшифровки и текстовое поле для вывода результата проверки.

Описание класса TCPClient.

Конструктор класса принимает:

  • IP адрес для подключения;
  • номер порта для подключения;
  • длину принимаемых (да и передаваемых тоже) данных от сервера в байтах, они равны длине ключа шифрования (в данном случае 128 байт).

Эти переменные конструктор копирует в глобальные переменные класса.

Основная функция класса public void runClient(Handler hndlr) принимает в качестве параметра ссылку на объект Handler куда потом будет в объект Bundle скидывать принятые сообщения от сервера и сообщения об ошибках:

  • создаём объекты Message и Bundle (для передачи сообщений в MainActivity);
  • выделяем память под входной буфер mSrvMsg;
  • выставляем флаг запуска подключения mRun;
  • в блоках try{} catch{} подключаемся к сокету сервера, подключаем буфера приёма и отправки сокета, запускаем цикл приёма данных (до прихода команды на отключение mRun = false), либо до выброса исключения;
  • в цикле ожидаем прихода данных (mBufIn.read(mSrvMsg, 0, MAXREADLEN) - использует блокирующий ввод);
  • как только прочитали всё сообщение (либо получили исключение) отправляем его (зашифрованное сообщение от сервера, либо соответствующую ошибку) MainActivity используя объект Handler.

Функция public int sendMessage(byte[] msg) принимает в параметрах зашифрованное сообщение и отвечает за отправку ответа серверу mBufOut.write(msg). Не забываем контролировать наличие пустого сообщения, нулевого mBufOut и отлавливать исключения IOException, socketexception: broken pipe (когда закрылся сервер).

Функция public void stopClient()освобождает ресурсы и выставляет mRun = false.

Примечание. Если IP адрес сервера недоступен, то никакой ошибки не будет при подключении, но и связи не будет — будет висеть поток. В реальном приложении нужна дополнительная проверка.

Описание класса RSAEncrypt.

Конструктор класса принимает:

  • размер ключа шифрования в битах;
  • имя файла открытого ключа;
  • имя файла закрытого ключа;
  • ссылку на контекст приложения для сохранения и чтения файлов ключа в закрытой папке приложения.

Эти переменные конструктор копирует в глобальные переменные класса.

Функция public int generateKeys() отвечает за создание пары ключей.

Функция public byte[] encryptMessage(byte[] orig_msg) отвечает за шифрование сообщения.

Функция public byte[] decryptMessage(byte[] orig_msg) отвечает за расшифровку сообщения.

Функция public int openKeysFromFS() отвечает за загрузку ключей с закрытого каталога приложения, для этого она использует функции:

  • int checkFile(String fname) — определение наличия файла с нужным именем;
  • byte[] readFile(String fname) — читает файл из закрытого каталога приложения.
    Конечное получение открытого ключа достигается конструкцией:
    KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM);
    EncodedKeySpec publicKeySpec = new X509EncodedKeySpec(tmp_key);
    mpublicKey = keyFactory.generatePublic(publicKeySpec);
    где
    ALGORITHM = “RSA”, tmp_key — байтовый массив прочитанного файла ключа.
    Конечное получение закрытого ключа достигается конструкцией:
    KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM);
    PKCS8EncodedKeySpec privateKeySpec = new PKCS8EncodedKeySpec(tmp_key);
    mprivateKey = keyFactory.generatePrivate(privateKeySpec);
    где
    ALGORITHM = “RSA”, tmp_key — байтовый массив прочитанного файла ключа.
    Не забываем отлавливать исключения
    NoSuchAlgorithmException, InvalidKeySpecException.

Функция public int saveKeysToFS() отвечает за сохранение ключей в закрытый каталог приложения. Используется функция writeFile, в которую передаётся имя файла и байтовый массив ключа (например writeFile(mPubfname, mpublicKey.getEncoded())), в свою очередь эта функция просто пишет в файл в закрытом каталоге приложения.

Функция public int exportKeys()отвечает за сохранение ключей в открытом каталоге приложения (в моём случае это «/android/data/com.example.RPI_wifi_link_02»). Функция перед записью файлов добавляет префикс и суффикс, преобразовывая байтовый массив ключа в строку. Для записи в файл в открытом каталоге служит функция public int exportFile(String fname, String wr_data).

Функция public int importKeys() отвечает за загрузку ключей из открытого каталога приложения (в моём случае это «/android/data/com.example.RPI_wifi_link_02»). Функция после чтения файлов удаляет префиксы и суффиксы (конвертируем в строку и используем функцию replace() класса String). Для чтения файла в открытом каталоге служит функция public byte[] importFile(String fname).

Примечания:

  • При открытии для чтения/записи в закрытом каталоге (внутреннее хранилище) используются конструкции:

fin_stream = mContext.openFileInput(fname);
fout_stream = mContext.openFileOutput(fname, mContext.MODE_PRIVATE);

При открытии для чтения/записи в открытом каталоге (внешнее хранилище), сначала требуется получить путь к внешнему хранилищу:

File file = new File(mContext.getExternalFilesDir(null), fname);
fin_stream = new FileInputStream(file);
File file = new File(mContext.getExternalFilesDir(null), fname);
if (!file.exists()) {
file.createNewFile();
}
fout_stream = new FileOutputStream(file);

  • При сохранении ключей в закрытом каталоге (внутреннее хранилище) используется mpublicKey.getEncoded() (по аналогии и закрытый ключ) для получения байтового массива и дальнейшей его записи в файл.

Для сохранения ключей в открытом каталоге (внешнее хранилище) используется string.getBytes(StandardCharsets.UTF_8), соответственно чтобы этот массив байт (при импорте после чтения из файла) преобразовать в строку, потребуется конструкция
str = new String(tmp_data, StandardCharsets.UTF_8);.

В то же время для скармливания KeyFactory, строку в массив байт необходимо конвертировать конструкцией
tmp_data = Base64.decode(str, Base64.DEFAULT);.

Описание кода MainActivity.

Функции: onCreate (наполнение MainActivity, получение ссылок на поля), onStart (создание экземпляра класса RSAEncrypt, загрузка ключей), onStop (останов обмена с сервером, освобождение ресурсов) типовые.

  • linkStatusUpdaterRunnable нужен для обновления состояния связи с сервером.
  • void sendAnswer() создаёт поток в котором осуществляется шифрование и отправка сообщения серверу.
  • mTCPLinkHandler принимает данные от потока TCPClient (функция runClient) и расшифровывает полученные данные. Ссылку на этот объект мы передаём в конструктор класса TCPClient.
  • void onConnectClick(View view) — обработчик нажатия на кнопку "Подключиться", здесь создаём объект TCPClient и запускаем поток с функцией runClient.
  • void onRSAKeysBtnClick(View view) — функция обработки нажатия на кнопки управления ключами шифрования.

Решение 2. Обменом данными с сервером заведует выделенный сервис

Схема взаимодействия приложений для случая управления подключением из сервиса
Схема взаимодействия приложений для случая управления подключением из сервиса

Решение для клиента (Android)

Для решения задачи создаётся 3 вспомогательных класса:

  • TCPClient: обмен данными со смартфоном по WiFi, файл «TCPClient.java»;
  • RSAEncrypt: операции с ключами (генерация, загрузка, выгрузка, импорт (в формате openssl), экспорт (из формата openssl)), шифрование, расшифровка данных, файл «RSAEncrypt.java»;
  • TCPClientService: (extends Service) обмен данными с сервером будет осуществляться из сервиса и не будет зависеть от поворотов экрана устройства. Файл «TCPClientService.java».

Классы TCPClient и RSAEncrypt полностью идентичны описанным в решении 1.

При поворотах экрана, а также при сворачивании главного окна приложения возможны ситуации когда MainActivity не сможет длительное время давать команды, на такой случай в TCPClientService реализован механизм контроля тайм аута ответа от MainActivity: если за положенное время ответ не пришёл, то осуществляется повторная отправка последнего успешного зашифрованного сообщения серверу. Кроме того про вызове onStop активности сбрасывается в null ссылка на приёмник сообщений MainActivity, в этом случае также сервисом осуществляется отправка последнего успешного зашифрованного сообщения серверу.

Описание класса TCPClientService.

При старте сервис принимает через Intent:

  • IP адрес для подключения;
  • номер порта для подключения;
  • длину принимаемых (да и передаваемых тоже) данных от сервера в битах, длина равна длине ключа шифрования (в данном случае 1024 бит).

Эти переменные сервис отправляет в конструктор класса TCPClient.

Функция onBind сервиса нужна нам только для возврата в MainActivity ссылки на сам сервис.

Основная функция сервиса находится в классе
private class IncomingHandler extends Handler, где переопределена функция public void handleMessage(@NonNull Message msg). Эта функция принимает входящие сообщения от TCPClient.

При подключении к сервису из MainActivity получаем ссылку на сервис (https://developer.android.com/guide/components/bound-services), которая позволяет вызывать напрямую открытые (public) методы сервиса. Осуществляется установка с помощью
TCPClientService::setActivityMessenger() адресата MainActivity (отправки ему сообщений с запросами от сервера).

Далее работает всё так:

  1. получаем сообщение от TCPClient (сервер прислал запрос);
  2. отправляем полученный зашифрованный массив в MainActivity (ссылка mtoActivityMessenger);
  3. запускаем функцию контроля своевременного получения ответа от MainActivity uiMsgRcvControl();
  4. MainActivity (TCPClientService::sendToServerData()) оправляет ответ серверу.

Функция private void uiMAworkControl() отвечает за контроль своевременного получения ответа от MainActivity (40 мс— при меньших значениях MainActivity не всегда успевает отработать). Для этого запускаем поток в котором отслеживаем время с момента отправки сообщения для MainActivity, если ожидаемое сообщение пришло до окончания тайм аута, то процесс ожидания прерывается. Если же ответ от MainActivity не пришёл во время, то отправляется последний ответ.

Функция public void setActivityMessenger(Messenger messenger)отвечает за получение от MainActivity адресата (сам же MainActivity) куда отправлять сообщения.

Функция public int sendToServerData (byte[] data) отвечает за отправку зашифрованных данных серверу (RPi).

Функция private void sendFakeData() отвечает за отправку зашифрованных данных серверу (RPi) когда MainActivity не активна. Также эта функция отвечает за отключение сервиса при длительной не активности MainActivity.

Добавлен элемент Switch для запоминания приложением состояния подключения к серверу при поворотах экрана (взял отсюда http://developer.alexanderklimov.ru/android/orientation.php#dissapear раздел «Запоминаем значения переменных»).

Итак, если элемент Switch не установлен, то при повороте подключение в MainActivity к сервису теряется, но на сервере данные приходят (одни и те же в решении), это хорошо тем что сетевое подключение не рвётся и не надо заново подключаться.

Описание классов TCPClient и RSAEncrypt такие же что и выше по тексту (в решении 1).