javascript

12 слов вместо номера телефона: как мы сделали мессенджер невидимым для файрволов

  • вторник, 31 марта 2026 г. в 00:00:06
https://habr.com/ru/articles/1016900/

Тот вечер я помню хорошо. Двадцать минут в звонке, пытаясь объяснить человеку как установить VPN. Через пару дней и этот способ тоже закрыли.

Но это была не единственная боль. Простой звонок другу в Москву, переписка с клиентом, групповой чат с командой - всё превратилось в логистическую задачу. Один сидит без VPN, у другого он не работает, третий не может установить нужное приложение. Люди тратят время не на разговор, а на то чтобы вообще выйти на связь.

А потом мой российский номер, к которому были привязаны Telegram и WhatsApp, «сгорел» из-за неактивности внутри РФ. Оператор просто выставил его на продажу. Через неделю кто-то купил эту симку и начал методично пытаться войти в мои аккаунты.

В какой-то момент стало понятно: это не проблема инструкций. Это проблема архитектуры. Приватная переписка не должна требовать технической подготовки. Телефон - это не вы. Это запись в базе данных оператора, которую можно купить, взломать или заблокировать.

Так появился Mist Messenger. Ниже - как это устроено, что сломалось, и почему некоторые решения были болезненными.

Почему существующие решения не работают

Telegram блокируют. VPN блокируют быстрее, чем пользователи успевают переключиться. Signal недоступен. Google Meet и Zoom работают с перебоями. Каждый раз когда появляется рабочий инструмент - находится способ его закрыть.

Причина простая: все эти инструменты видны. У каждого мессенджера есть сетевой fingerprint - уникальная подпись, по которой системы глубокой инспекции пакетов его опознают. Нативные мессенджеры с кастомными TLS-библиотеками определяются DPI по JA3/JA4 отпечатку мгновенно. Можно усложнять протокол, обфусцировать трафик, менять порты - но сам факт что ты что-то скрываешь уже делает тебя мишенью.

Mist работает в браузере и устанавливается как PWA за 5 секунд. Для систем блокировок его трафик выглядит как обычный сайт: TLS fingerprint - настоящий Chrome или Safari, тот же протокол, никакого характерного паттерна. Внутри при этом - полноценное E2EE шифрование. Невидим для файрвола, приватен для пользователя.

Это не компромисс, а архитектурное решение, которое мы не планировали. Просто повезло с выбором платформы.

Авторизация: 12 слов вместо номера телефона

В Mist аккаунт - это seed-фраза из словаря BIP39. Та же механика, что защищает криптокошельки: 12 случайных слов, из которых математически выводится ваша личность в системе. Генерируется на вашем устройстве, нигде не хранится, никуда не отправляется.

Технически из seed через HMAC-SHA256 детерминированно выводятся два ключа: ключ идентичности (ECDSA P-256) для подписей и ключ шифрования (ECDH P-256) для создания shared secret с каждым собеседником. Это две отдельные пары ключей с разным назначением. Identity key подписывает challenge при логине - Zero-Knowledge Auth: сервер отправляет случайный challenge, клиент подписывает приватным ключом, сервер проверяет подпись публичным. Пароль никогда не передаётся. Encryption key участвует в ECDH key agreement для вычисления shared secret.

Здесь меня справедливо спросят: почему HMAC-SHA256, а не Argon2id? Честный ответ - потому что seed-фраза BIP39 уже имеет 128 бит энтропии (12 слов × log₂(2048) ≈ 132 бита). Для сравнения: перебор 2¹²⁸ комбинаций на всех GPU мира займёт больше времени, чем возраст вселенной. Argon2id нужен, когда пользователь вводит слабый пароль вроде qwerty123. Когда пароль - это 12 случайных слов из словаря в 2048 - key stretching не даёт практической пользы. Тем не менее, Argon2id запланирован на v2 - не потому что это закрывает реальную атаку, а потому что это закрывает вопросы аудиторов.

Non-extractable CryptoKey: ключ, который нельзя украсть

Приватные ключи хранятся как non-extractable CryptoKey через Web Crypto API. Удивительно мало проектов это используют, хотя механизм мощный.

Когда вы создаёте ключ через crypto.subtle.generateKey() с параметром extractable: false, браузер создаёт объект CryptoKey, который можно использовать для криптографических операций (подпись, расшифровка, ECDH deriveBits), но нельзя экспортировать. Вызов crypto.subtle.exportKey() вернёт ошибку. Даже если вредоносный скрипт получит доступ к JavaScript-контексту - он не сможет вытащить приватный ключ.

На практике: XSS-атака может вызвать decryptMessage() и получить расшифрованный текст, но не может украсть сам ключ для офлайн-использования. Без ключа атакующий должен поддерживать активную сессию, а не просто один раз забрать ключ и уйти.

CryptoKey хранится в IndexedDB - единственном хранилище браузера, которое поддерживает structured clone algorithm для непримитивных объектов. localStorage и sessionStorage работают только со строками.

Шифрование: когда «как у всех» - это хорошо

В криптографии изобретать велосипед - плохая идея. E2EE в Mist - классика: ECDH P-256 для обмена ключами, AES-256-GCM для шифрования. Скучно, но надёжно.

Когда вы пишете собеседнику, ваше устройство выполняет ECDH key agreement: ваш приватный ключ × публичный ключ собеседника → shared secret (256 бит). Из этого raw секрета через SHA-256 выводится симметричный ключ AES-256. Каждое сообщение шифруется с уникальным 96-битным IV через crypto.getRandomValues(). AES-256-GCM обеспечивает одновременно шифрование и аутентификацию - модификация пакета приведёт к провалу расшифровки.

Shared secret между двумя пользователями одинаков в обе стороны (свойство ECDH: A_priv × B_pub = B_priv × A_pub). Публичные ключи хранятся на сервере. При первом контакте клиент запрашивает публичный ключ собеседника и кеширует на 5 минут. Shared secret кешируется на 30 минут, причём хранятся и возвращаются .slice() копии - защита от cache poisoning через .fill(0).

Групповые чаты: один ключ на всех не работает

В DM всё просто: два человека, один shared secret. В группе из N человек нужно N×(N-1)/2 попарных секретов - это не масштабируется.

Решение: отправитель генерирует случайный message key для каждого сообщения. Текст шифруется этим ключом через AES-256-GCM. Затем message key «оборачивается» индивидуально для каждого участника через их ECDH shared secret. Каждый получатель расшифровывает только свою копию message key, а затем - само сообщение. Сервер хранит зашифрованный текст + массив обёрнутых ключей.

Эфемерные ключи и forward secrecy

Помимо долгоживущих identity-ключей, для каждой пары собеседников создаётся эфемерная сессия - отдельная ECDH-пара, живущая 24 часа. Из эфемерного shared secret через HKDF (RFC 5869) с identity binding выводится сессионный ключ.

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

Дополнительная защита - nonce replay protection с 5-минутным окном свежести. Использованные nonce хранятся в LRU-кеше: при заполнении 20% удаляется без полной очистки.

Здесь был баг с Safari. WebCrypto в Safari не поддерживает JWK-импорт ECDH-ключей. Пришлось конвертировать через raw координаты и собирать PKCS8 DER вручную с полным ASN.1 algorithm identifier. Задокументировано прямо в коде, потому что следующий человек потратит на это столько же времени.

Safety numbers и один исправленный bias

Для защиты от MITM реализованы safety numbers - как в Signal. Каждая пара может сравнить числовой код из своих публичных ключей.

Алгоритм: оба ключа (uncompressed P-256, 65 байт каждый) сортируются лексикографически, конкатенируются, хешируются SHA-256 дважды с разными seed - 64 байта. Из них через rejection sampling - 60 десятичных цифр.

Первая реализация использовала mod 100 - и это был bias. Байт 0–255 при делении на 100 даёт неравномерное распределение: значения 0–55 выпадают чуть чаще. Исправлено: принимаются только байты < 200 (ровно 2×100 в диапазоне 0–199). Acceptance rate: 78%. С 64 входными байтами ожидается ~50 принятых - более чем достаточно для 60 цифр.

Звонки: LiveKit, E2EE и два бага

Голосовые звонки через self-hosted LiveKit SFU. Сервер ретранслирует зашифрованные RTP-пакеты и не может их прочитать.

Для DM-звонков ключ выводится через ECDH с domain separator: SHA-256("mist-call-key-v1:" sharedSecret roomName). Domain separator гарантирует: утечка call key не компрометирует message key.

Для групповых - инициатор генерирует 32-байтный ключ и шифрует его для каждого участника через ECDH. Бэкенд хранит массив encryptedCallKeys, но принципиально не включает их в API-ответы и broadcast - только при join, и только свой.

Баг первый: инициатор раздавал ключи всем - кроме себя. При переподключении не мог получить ключ обратно. Фикс - одна строка: включить currentUserId в Set получателей.

Баг второй: бэкенд не различал DM и групповые звонки. Теперь для DM encryptedCallKeys игнорируются принципиально - ключ выводится только на клиенте.

LiveKit токены выдаются с canPublishData: false (блокирует data channels, обходящие media E2EE), TTL 15 минут, одноразовые. Rate limiting: 5 звонков в минуту.

Конференции - отдельная фича. Ссылка без регистрации, зал ожидания, хост впускает вручную. Демонстрация экрана: 1920×1080 VP9 при 8 Mbps с contentHint: 'detail' - итог: хороший аналог Zoom и Google Meet для рабочих звонков.

Стек: Bun, Hono, PostgreSQL

Бэкенд на Bun. Встроенный Bun.serve держит тысячи WebSocket-соединений без захлёбывания. Hono - лёгкий, типизированный через Zod. PostgreSQL через Prisma + pgBouncer. Атомарность через транзакции. Race conditions закрыты на уровне БД.

PWA: осознанная боль

Первое с чем сталкивается каждый - установка. Это не кнопка «Загрузить». На iPhone: открыть ссылку в Safari, нажать «Поделиться», выбрать «На экран домой». Звучит просто, но это барьер. Мы решили его в интерфейсе - при открытии в браузере появляется баннер с инструкцией для вашего устройства. Пять секунд.

Второй страх - «пропущу сообщение». Mist поддерживает push-уведомления через Service Worker: на Android сразу, на iOS после добавления на домашний экран.

iOS и микрофон: при сворачивании PWA система убивает MediaStreamTrack микрофона. Мы перебрали шесть подходов: PiP видео, echo loop, Web Audio oscillator, silent audio + MediaSession, HD fake getUserMedia - ни один не работает. Safari в standalone PWA принципиально убивает mic при background. Работает: WakeLock (экран не гаснет), автовосстановление при возврате, честный toast «не сворачивайте во время звонка». Нативное приложение - следующий этап, код готов на 80%.

Edge swipe - ещё одна боль. Safari интерпретирует свайп от края как history.back(). В SPA это ведёт к белому экрану. Решение: touchstart listener с preventDefault() в первых 20 пикселях от краёв, { passive: false }. Полностью блокирует навигационный жест.

Россия: ТСПУ, DPI и честный разговор

Mist доступен из России без VPN. TURN over TCP 443 для звонков неотличим от HTTPS. Браузерный TLS fingerprint проходит DPI. Домен напрямую на IP сервера без CDN-прослойки.

Ирония из практики: один пользователь написал «без VPN работает, а с VPN - нет». VPN маршрутизировал через страну, где IP хостера был заблокирован по другим причинам. Инструмент обхода блокировок сам стал причиной недоступности.

Почему это хрупко: ТСПУ работает на нескольких уровнях. PWA проходит сигнатурный анализ и JA3/JA4 автоматически. Но если IP попадёт в чёрный список - придётся менять. Если Россия перейдёт к модели белых списков - любой зарубежный сервис без договорённостей окажется недоступен. Пока окно открыто - Mist работает. Обещать большее было бы враньём.

Что получает пользователь

За всей этой криптографией - продукт для людей. Не абстрактный «защищённый мессенджер», а конкретные вещи которые работают каждый день.

Личные/групповые чаты, голосовые звонки с E2EE. Конференции по ссылке до 10 человек с демонстрацией экрана - участник входит без регистрации. AI-ассистент (Gemini, GPT, Grok) встроен в интерфейс и работает из России без VPN. Сообщения с таймером самоуничтожения. Публичные каналы. Инвайт-коды. Полная локализация EN/RU.

Открытый код

Весь криптографический код - в публичном репозитории: github.com/Mist-Messenger/mist-messenger-security

Вместо заключения

Технологии цензуры совершенствуются. Мы тоже. Mist строился не просто как мессенджер - а как доказательство того, что право на приватность это не привилегия, а математическая константа. 

Мы продолжаем эту гонку, чтобы вам больше не приходилось объяснять близким как настроить VPN - просто чтобы сказать «привет».

mist-app.com