Перейти к содержимому

Шифрование звонков

Аудио, видео и демонстрация экрана в звонках Floodilka шифруются сквозным шифрованием (E2EE): кадры шифруются в браузере отправителя и расшифровываются только в браузерах остальных участников. Медиасервер (LiveKit SFU) пересылает уже зашифрованные кадры и не может их прочитать.

Кто Видит ли медиа
Пассивный наблюдатель в сети Нет — поверх E2EE есть ещё DTLS-SRTP до SFU
LiveKit SFU (в т.ч. на стороннем хостинге) Нет — получает только зашифрованные кадры
Скомпрометированный медиасервер Нет — ключа у него нет, он никогда не попадает в LiveKit-токен
Бэкенд и gateway Floodilka Да — они генерируют и раздают ключ комнаты
Участник звонка Да — это и есть смысл общего ключа комнаты

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

  1. Бэкенд генерирует для комнаты 32 случайных байта (crypto.randomBytes) и кладёт их в Redis по ключу voice:room:e2ee:guild:{guild_id}:{channel_id} либо voice:room:e2ee:dm:{channel_id}. Создание атомарное (SET … NX GET), поэтому все участники одной комнаты гарантированно получают один и тот же ключ.
  2. Ключ возвращается клиенту в ответе на выдачу голосового токена и не кладётся в LiveKit-токен — ни в grant, ни в metadata. Токен проверяет SFU, поэтому всё, что лежит внутри токена, для SFU читаемо; ключ туда попасть не должен.
  3. Gateway пересылает ключ в событии VOICE_SERVER_UPDATE (поля e2ee_key и e2ee_required) и сам его никак не интерпретирует.
  4. Клиент декодирует base64 в сырые байты, отдаёт их ExternalE2EEKeyProvider из livekit-client (сырые байты означают HKDF; строка означала бы PBKDF2 по парольной фразе), включает шифрование и только после этого подключается к комнате.
  5. Ключ живёт, пока живёт сама комната, и удаляется по вебхуку room_finished или при смене rtc-региона. TTL у ключа намеренно нет.

Пятый пункт стоит объяснить: ключ обязан переживать перезакрепление комнаты за сервером. Закрепление сбрасывается в том числе тогда, когда конкретному пользователю закреплённый сервер недоступен по персональным ограничениям, — и это ничего не говорит о том, занята комната или нет. Если бы ключ уходил вместе с закреплением, а комната затем вернулась на тот же сервер, уже подключённые участники остались бы со старым ключом, а следующий вошедший получил бы новый: все друг друга видят, но медиа не расшифровывается. По той же причине у ключа нет TTL — сутки непрерывного разговора без единого нового захода обнулили бы ключ под работающей комнатой.

Поле Тип Значение
e2ee_key string | null Ключ комнаты в base64 (32 байта). null, если шифрование выключено на инстансе
e2ee_required boolean Если true, клиент обязан подключаться только с включённым шифрованием

Оба поля в событии присутствуют всегда: если бэкенд их не прислал, гейтвей подставляет null и false. Проверки на null достаточно, отсутствующее поле обрабатывать не нужно.

При e2ee_required = true клиент, который не умеет шифровать (нет insertable streams), не подключается и показывает ошибку. Тихого отката в незашифрованную сессию нет.

Если ключ пришёл, но воспользоваться им не удалось (браузер не поддерживает insertable streams, ключ не декодируется, воркер не создался), а e2ee_required = false, клиент подключается незашифрованным — но помечает соединение как проблемное и показывает «Ошибка ключа», а не «Транспортное». Остальные участники комнаты в этот момент шифруют, то есть такой участник их не слышит, и вид здорового соединения тут вводил бы в заблуждение.

Инстанс управляет этим через VOICE_E2EE_ENABLED и VOICE_E2EE_REQUIRED (обе по умолчанию true).

Клиент, который про e2ee_key ничего не знает, зайдёт в комнату без шифрования: старые вкладки с закешированным бандлом, не обновлённые сборки для компьютера, сторонние боты. В одной комнате с шифрующимися участниками такой клиент не услышит их, а они — его, и на его стороне не будет никакой диагностики, потому что он про шифрование не в курсе. Версионного гейта в схеме нет.

От смешанных комнат защищает только VOICE_E2EE_ENABLED=false: при нём ключ не выдаётся и не шифрует никто. Порядок выката:

  1. Выкатить бэкенд и клиент с VOICE_E2EE_ENABLED=false.
  2. Дождаться оборота клиентов.
  3. Включить VOICE_E2EE_ENABLED=true.

VOICE_E2EE_REQUIRED для этой задачи не годится и работает не так, как можно ожидать. Пока ключ выдаётся, каждый способный клиент шифрует независимо от значения required — флаг управляет не шифрованием, а тем, что делать с клиентом, который шифровать не может. При VOICE_E2EE_REQUIRED=false такой клиент заходит в комнату незашифрованным: он не услышит остальных и остальные не услышат его, причём без единого сообщения об ошибке. Поэтому значение по умолчанию true — оно превращает эту ситуацию в понятный отказ подключиться. Ставить false осмысленно только там, где сознательно нужен деградированный режим.

  • Не zero-trust. См. врезку выше: раздача ключей идёт через наш контрол-плейн.
  • Ключ живёт всю сессию звонка и не ротируется. В групповых ЛС состав участников может меняться прямо во время звонка, и удалённый из группы участник сохраняет ключ до конца звонка. Использовать его он сможет только пока у него есть доступ к шифротексту, то есть пока он остаётся подключённым к комнате SFU — принудительного отключения из активного звонка при удалении из группы платформа сегодня не делает независимо от шифрования. Ротация без переподключения требует отдельного gateway-события для рассылки нового ключа оставшимся и в текущей схеме не реализована.
  • Метаданные не скрыты. Кто, когда, с кем и как долго говорит, кто говорит в данный момент, громкость, битрейт, размер кадров — всё это остаётся видно серверу. Шифруется содержимое медиа, а не факт звонка.
  • Серверные функции, которым нужно читать медиа, работать не будут. Запись звонков и LiveKit egress получают только шифротекст.
  • Только медиа. Текстовые сообщения, вложения и всё остальное содержимое E2EE не покрыто — они хранятся и обрабатываются сервером в открытом виде.
  • Весь криптообмен комнаты идёт в одном воркере. На подключение создаётся один воркер insertable streams, и он же расшифровывает кадры всех удалённых участников. Для аудио это незаметно, но в канале с несколькими включёнными камерами упор будет в этот поток.
  • RED (аудиоизбыточность) отключается автоматически. livekit-client выключает RED при включённом E2EE; на нестабильной сети это может слегка ухудшить устойчивость аудио к потерям.

Нужна поддержка insertable streams (Encoded Transform): актуальные Chrome/Edge, Safari 15.4+ и приложение для компьютера. Firefox поддерживает их не во всех сборках — при e2ee_required = true там подключение будет отклонено с понятной ошибкой.