Это статья про небольшой хобби-проект, или как написать очень легковесный клиент для любого приложения с нуля.

Идея зародилась, когда весь этот цирк только начинался. Прогревали новый ГОСТ-мессенджер, поливали его чем только можно. В какой-то момент проскочила новость: В Max нет сквозного шифрования...

И тут зародилась глупая идея: почему бы самому не прикрутить к нему разные функции анонимности и шифрования? Уровень конечно совсем не тот, что у Telegram и Signal, но сама возможность скрыть содержание переписки завораживает, разве нет?!

Спойлер: APK весит 11 МБ против 120 МБ у официального, не греет телефон и показывает каждый запрос к серверам ok[.]ru и vk[.]ru

Дисклеймер

Итоговое приложение не является полноценной заменой официального клиента — невозможно перенести весь функционал за пару вечеров. Не является оно и полноценной заменой Signal, поскольку я не специалист в сфере кибербезопасности.

Кроме того: какая бы не была криптография, нужно думать о MITM, возможной краже токенов и даже XSS (подробнее ниже). Да и товарищ майор, вероятно, сильно удивится вашему чату, состоящему из набора случайных символов. Короче, вы поняли.

Выбор инструментов

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

Есть много инструментов для создания приложений, но больше из них выделяется Tauri. Почему? Я перепробовал 10+ различных фреймворков (на Java, Python, да даже на Go) и самым продвинутым и неприхотливым показался именно этот.

Если вкратце, то Tauri — это современный фреймворк, в котором приложения работают через WebView устройства. Это значит, что клиент будет разработан на JS. Да, почти как в Electron... Но без встроенного браузера. Да, почти как Svelte Native... Но... через браузер.

У Tauri небольшой размер бандла, нативный бридж к Rust и использование WebView. Более того в Tauri можно выбрать любой веб-стек, мне приглянулся Svelte. Слава богу, недавние взломы в реестре npm его не затронули. Этот сегмент не спонсирован командой Tauri / Svelte!

Как устроен мессенджер?

Общение с сервером довольно простое, пакеты можно анализировать через PCAPdroid или веб-версию. Сначала вы просите инициализацию с user‑agent, за ним посылаете запрос на синхронизацию, а вам в ответ: контакты, чаты, все настройки и так далее. Вебсокет почти ничем не отличается от сокета в мобильной версии.

Протокол и устройство пакетов оказались на удивление простыми. Подробнее можно прочитать в этом подробном отчете по реверс-инжинирингу от koval01:

https://gist.github.com/koval01/b0baae9a9a0ee4f0d65c0d5377d9b243

Для работы с API мессенджера я перенёс отличный проект pymax на [blazingly fast!] Rust. Добрые люди уже давно разобрали мессенджер по кускам и даже сделали библиотеку на Python, и целый кастомный клиент Komet!

Каркас нового клиента

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

Итак, как пример, схема получения кода подтверждения с нашим фреймворком такая:

  1. Пользователь вводит номер телефона в <form> и жмёт кнопку

  2. WebView обращается к бекенду как-то так: invoke(“start_login”, { phone })

  3. Rust-бекенд принимает запрос и общается с серверами Max

  4. Max отправляет SMS на указанный номер

Скрытый текст

Немного кода из самого клиента (значительно сокращено)

1) Внутри окна src/components/Login.svelte вызываем invoke():

const code = someForm.value;
const checkCode = await invoke("check_code", { code });

2) Внутри обработчика src-tauri/commands.rs обращаемся к библиотеке:

#[tauri::command]
pub async fn check_code(
    state: State<'_, AppState>,
    code: String,
) -> Result<Value, String> {
    state
        .client
        .check_code(code)
        .await
        .map(|r| json!({ "success": true, "payload": r.payload }))
        .map_err(|e| format!("Ошибка подключения: {}", e))
}

3) В библиотеке формируем запрос к серверу и возвращаем результат:

pub async fn check_code(&self, code: String) -> ClientResult<Response> {
  let payload = json!({
    "authTokenType": "CHECK_CODE",
    "token": self.TEMP_TOKEN,
    "code": code
  });
  
  let resp = self.send_and_wait(Opcode.CHECK_CODE, payload, 0).await?;
  
  Ok(resp)
}

Если бы я не сделал библиотеку отдельным крейтом, то третий пункт был бы слиян со вторым и кода было бы намного меньше.

Но текущий вариант с выделением библиотеки лучше, т.к в будущем я хочу сделать принимающую сторону для туннелирования трафика, а для него Tauri не нужен (подробнее что за туннелирование — в разделе про звонки!)

Аналогичным образом работает синхронизация, отправка сообщений и любые другие запросы.

Для сохранения данных пользователя, в том числе чатов и ключей шифрования, использовал Svelte Store и плагин Tauri Store. По-хорошему их шифровать, но у нас пока только прототип.

End-to-end шифрование

Самое интересное начинается здесь.

Безопасность реализована в два слоя. Когда один из участников чата нажимает кнопку «Включить шифрование», все сообщения в чате начинают обфусцироваться — это поверхностная безопасность. Иначе говоря, Привет! становится 大象 (и нет, это не перевод на китайский)

Скрытый текст

Функция обфускации. Не оптимизирована на сотни сообщений, но свое дело делает.

function obfuscate(text, language, compression = "max") {
  const alphabet = ALPHABETS[language];

  const level = compression === "max" ? 9 : 1;
  const inputBytes = fflate.strToU8(text);
  const compressedBytes = fflate.deflateSync(inputBytes, { level });

  const payload = encodeBitPacked(compressedBytes, alphabet);
  const marker = makeMarker(alphabet, 5);
  return marker + payload;
}

Библиотека fflate сжимает содержимое сообщения, encodeBitPacked применяет маппинг «символ <-> обфусцированный символ», а makeMarker создает пометку того, что сообщение обфусцировано кастомным клиентом.

Кроме того, клиент генерирует и создает пару приватного и публичного ключей. Для подписи сообщений используется Ed25519, а для обмена секретами — Curve25519 через libsodium.

Когда пользователь отправляет сообщение, клиент не шифрует его напрямую ключом собеседника. Вместо этого генерируется случайный симметричный ключ сообщения (content key), которым и шифруется текст через ChaCha20-Poly1305.

Дальше применяется схема envelope encryption. Для каждого участника чата создается отдельная обертка с помощью временного одноразового ключа и ECDH-обмена. Иначе говоря, сервер видит только набор бинарного мусора и несколько служебных полей, но не содержимое сообщения.

Скрытый текст

Функция шифрования сообщения на libsodium-wrappers

async function getEncrypted(senderId, identity, recipients, plaintext) {
  // Создаем временную пару ключей
  const eph = sodium.crypto_box_keypair();
  // Генерируем случайный симметричный ключ сообщения
  const contentKey = sodium.randombytes_buf(
    sodium.crypto_aead_chacha20poly1305_ietf_KEYBYTES,
  );

  // Целостность (версия, отправитель, временная метка)
  const timestamp = Date.now();
  const aad = sodium.from_string(`1|${senderId}|${timestamp}`);

  // Шифруем сообщение
  const { content_nonce, ciphertext } = encryptWithContentKey(
    contentKey,
    plaintext,
    aad,
  );

  const wrappers = [];
  // Для каждого клиента шифруем отдельно
  for (const r of recipients) {
    const r_curve = base64UrlToBuf(r.c);
    // ECDH (обмен секретом)
    const shared = sodium.crypto_scalarmult(eph.privateKey, r_curve);

    // Кек (key encryption key)
    const kek = deriveKek(shared, CONTEXT_WRAP);

    const { wrapped, wrap_nonce } = wrapContentKey(kek, contentKey);
    wrappers.push({
      id,
      wrapped,
      nonce
    });
  }

  // Пакет сообщения, который будет отправлен
  const envelope = {
    senderId,
    ephemeral_pub,
    content_nonce,
    cipherText,
    wrappers,
    timestamp
  };

  // Создаем подпись
  const bytesForSign = canonicalizeForSign(envelope);
  const sig = signEnvelope(base64UrlToBuf(identity.eds), bytesForSign);

  // Добавляем в пакет подпись и публичный ключ
  envelope.signature = bufToBase64Url(sig);
  envelope.sender_ed25519_pk = identity.edp;

  return envelope;
}

Упрощённо схема выглядит так:

Сообщение

ChaCha20-Poly1305

Зашифрованный payload

Content Key отдельно заворачивается для каждого получателя

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

Разумеется, до уровня Signal здесь еще очень далеко. В прототипе нет Double Ratchet, защиты от повторной отправки пакетов, безопасного хранения ключей и нормальной верификации собеседников. Более того, вся криптография сейчас выполняется прямо внутри WebView через JavaScript, а значит XSS или компрометация клиента автоматически ломает всю защиту.

Тем не менее даже такая схема уже делает переписку значительно менее прозрачной для серверной стороны и всяких «любопытных посредников».

Проблемы и ограничения

Сервер все еще знает, кто с кем и когда общается, может анализировать длину сообщений и даже расшифровать сообщения через лет эдак 5–10.

Если Max будет ставить нейросети, смотреть сообщения и активнее сносить аккаунты, то появится смысл внедрить сокрытие через стеганографию.

Например, можно взять текст второго тома «Война и Мир», загрузить его в клиент и отправлять фрагменты текста с намеренными ошибками (шифруя в них биты). Или скрывать через аномалии частот в opus-потоке звонков. Или по методу LSB в изображениях.

Так, а что со звонками?

Текстовые сообщения — только половина задачи. Намного интереснее защищенные звонки.

Пока это лишь задумка, но схема могла бы быть примерно такой же: клиенты выполняют ECDH-обмен, получают общий session key, аудио кодируется через Opus и каждый аудиофрейм шифруется ChaCha20-Poly1305.

И уже во время звонка ключ периодически ротируется. При этом сам Мах видел бы только поток бинарных UDP/WebRTC-пакетов, но не содержимое. Я также предполагаю, что сервер может сжимать данные во время звонка (буферизация, транскодирование), и тогда нужно добавлять потоковое восстановление пакетов.

Через звонки также можно реализовать туннелирование трафика. Клиент буквально «звонит» другому с доступом вне Чебурнета и устанавливается защищенное соединение, через которое прогоняется часть трафика устройства и можно без страха послушать Ивана Алексеева.

Обязательно приведу уже готовую реализацию этого концепта через иные ру-сервисы:
https://github.com/kulikov0/whitelist-bypass

Для расширения полосы трафика можно включать Трансляцию экрана (хотя я не до конца уверен, что в Махе она есть)

Разумеется, реализовать это всё уже на порядок сложнее, чем текстовый чат. Особенно если добавлять безопасность в виде NAT traversal, синхронизацию ключей и защиту от MITM.

Safe Payload и нестандартные типы сообщений

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

В нем можно реализовать:

  • кастомные фоны чатов,

  • локальные реакции и эффекты,

  • самоуничтожающиеся сообщения,

  • скрытые типы вложений,

  • собственные стикеры и emoji packs,

  • дополнительные метаданные, которые официальный клиент просто не увидит.

Сервер при этом хранит такой payload как обычный бинарный blob, не понимая его внутреннюю структуру.

По сути, включение E2E превращает мессенджер в транспортный слой для любых своих данных.

Выводы

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

По ходу разработки появилась интересная идея на кастомные payload прямо в сообщениях, что позволяет добавить в клиент альтернативные функции.

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

Сейчас же я занимаюсь системой плагинов (как в клиентах AuyGram / exteraGram), а полный релиз будет, вероятно, даже не в этом году. Вперед, цифровое сопротивление!

P.S. Заинтересовались? Буду рад вашей помощи — проект тут.

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


  1. anzay911
    02.06.2026 17:54

    Может, вместо стеганографии base64 с рандомными пробелами?