
Тот вечер я помню хорошо. Двадцать минут в звонке, пытаясь объяснить человеку как установить 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 - просто чтобы сказать «привет».
