# Аудиозвонки STIX Chat

Поддерживаются личные звонки между двумя участниками обычного чата. Групповые звонки, звонки AI и посетителям виджета консультаций в этот протокол не входят.

## Клиент и границы поддержки

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

Фоновый путь входящего звонка использует PushKit и CallKit, отдельный VoIP-токен устройства и настроенный APNs на backend. Он требует подписанного приложения с соответствующими capabilities. Доставка на заблокированный iPhone и работа активного разговора в фоне требуют проверки на реальных устройствах; симулятор и проверка типов этого не подтверждают. Реализация backend API сама по себе не добавляет звонки в web-клиент.

Приватные ключи аккаунтов по-прежнему хранятся с Keychain `WhenUnlockedThisDeviceOnly`. Уже установленный разговор и загруженный аккаунт могут работать в фоне; при холодном запуске выгруженного приложения на заблокированном iPhone ключи недоступны до разблокировки. Такой входящий вызов закрывается, если аккаунт не удалось восстановить, и повторный вход становится доступен после разблокировки. Эта версия не ослабляет защиту ключей ради фонового старта.

## Защита

Аудиопоток передаётся WebRTC с DTLS-SRTP между устройствами. TURN при необходимости пересылает зашифрованные пакеты. Запись или расшифровка аудио на backend и TURN не реализована.

SDP и ICE-кандидаты перед отправкой шифруются в `Envelope` для обоих участников и подписываются ключом аккаунта. Получатель проверяет подпись и привязку сигнала к звонку до передачи в WebRTC. Открытый SDP или ICE-кандидат в поле `payload` недопустим. Подмена/повтор сигналов из другого звонка должны отклоняться клиентом. Уведомления о завершении передают только пустой объект.

Протокол использует [формат сообщений версии 1](./SECURITY.md). Поле `MessageContent.text` содержит JSON сигнала, `attachments` равно `[]`, `replyTo` отсутствует. Для `offer`/`answer` JSON имеет `{type, sdp}`, где `type` точно совпадает с `kind`. Для ICE — `{candidate, sdpMid?, sdpMLineIndex?}`, причём нужен хотя бы один из `sdpMid` и `sdpMLineIndex` (нулевой индекс допустим). Неприменимые поля отсутствуют. Объект сериализуется в UTF-8; порядок ключей JSON не является частью протокола.

Вместо обычного message clientId для AES-GCM AAD и канонической подписи используется строка `STIX audio call v1:<callId>:<kind>:<senderId>:<clientId>`. `chatId` входит в стандартные AAD и подпись сообщения. Все UUID в контексте — именно значения полей REST без перекодирования; внешнее поле `clientId` остаётся UUID. Ключи `Envelope.keys` должны точно соответствовать двум участникам звонка. Получатель сверяет подпись с сохранённой публичной идентичностью собеседника, проверяет context и не применяет один `clientId` повторно. Терминальные `end`/`decline` авторизуются сессией и не имеют зашифрованного содержимого.

SDP допускает одну аудиосекцию `m=` с `UDP/TLS/RTP/SAVPF`, требует SHA-256 fingerprint сертификата и запрещает `a=crypto`; предел plaintext SDP — 24 KiB. Сервер не может проверить содержимое зашифрованного SDP: эти проверки выполняет получатель.

Серверу доступны идентификаторы участников/чата/звонка, тип сигнала, времена, статусы и размеры ciphertext. TURN видит сетевые адреса и объём трафика. Каталог публичных ключей контролируется сервером; для проверки идентичности собеседника сравнивайте отпечатки ключей через доверенный канал. Эти звонки не являются независимым аудитом криптографического протокола.

## API

Все методы требуют `Authorization: Bearer <token>` и доступа к обоим участникам личного чата. Интеграционному токену нужны `read` для чтения и `write` для отправки; наличие токена не заменяет приватные ключи для сигнального `Envelope`.

| Метод | Путь | Результат |
| --- | --- | --- |
| POST | `/api/v1/chats/{chatId}/calls` | Начать звонок; `201 {call, iceServers}` |
| GET | `/api/v1/chats/{chatId}/calls/{callId}` | Получить `{call, iceServers}` |
| GET | `/api/v1/chats/{chatId}/calls/{callId}/signals?after=0` | Получить `{call, signals}` после числового cursor |
| POST | `/api/v1/chats/{chatId}/calls/{callId}/signals` | Передать `{kind, clientId, payload}`; получить `201 {call, signal}` или `200` при повторе |
| PUT | `/api/v1/push/voip` | Привязать PushKit token к пользовательской сессии; `204` |
| DELETE | `/api/v1/push/voip` | Удалить PushKit token текущей сессии; `204` |

`kind` — `offer`, `answer`, `ice`, `end` или `decline`. Для `offer`, `answer`, `ice` поле `payload` содержит `Envelope`, для `end`, `decline` — `{}`. `clientId` — UUID логической отправки. При неопределённом результате повторяйте тот же запрос с прежним `clientId` и тем же ciphertext; не создавайте новый offer при каждом сетевом повторе.

Состояния звонка: `ringing`, `active`, `ended`, `declined`, `missed`. Два последних значения вместе с `ended` терминальные. Клиент должен завершать локальный захват микрофона при терминальном статусе, потере сессии и смене аккаунта. При отказе собеседника сервер не должен создавать активный звонок.

`call` содержит `id`, `chatId`, `initiatorId`, `recipientId`, `status`, `createdAt`, `updatedAt`, необязательный `endedAt`. Сигнал содержит числовой `id`, `callId`, `chatId`, `senderId`, `clientId`, `kind`, `payload`, `createdAt`. Ответ на терминальный no-op после истечения звонка может содержать `signal:null`: состояние `call` при этом обязательно обработать.

Звонок ожидает ответа 90 секунд. Активный звонок завершится, если любой участник не обновлял свой heartbeat через poll/send 60 секунд; polling нужен и во время разговора. Инициатор отправляет один `offer`, получатель — один `answer` после offer. Отклонить `ringing` может получатель, завершить — любой участник. Сервер допускает один незавершённый звонок пользователя во всех чатах, не более 512 сигналов на звонок и ограничивает создание 10 попытками в минуту на пользователя / 5 в одном чате. Терминальные сигналы доступны после достижения лимита.

Исходящая сторона привязывается к токену, создавшему звонок, входящая — к токену, который первым принял звонок. Другое устройство того же аккаунта не должно продолжать отправлять SDP/ICE или поддерживать heartbeat за выбранное устройство. Ошибки `call_answered_elsewhere` / `call_device_mismatch` (`409`) означают необходимость закрыть локальный экран звонка, сохранив разговор на принятом устройстве.

Максимальный сериализованный `payload` — 96 KiB. Сигналы активного звонка сохраняются; при создании новых звонков выполняется ограниченная очистка сигналов разговоров, завершённых не менее 30 минут назад. Это минимальный срок до возможного удаления, а не гарантия удаления ровно через 30 минут.

Типовые ошибки: `400` — неверный сигнал, envelope, cursor или неподдерживаемый вид чата; `403` — неверная сторона offer/answer/decline или блокировка; `404` — звонок недоступен; `409` — занят, звонок завершён или состояние/повтор запроса конфликтует; `429` — лимит. При `409` перечитайте звонок, прежде чем принимать решение о повторе. Не скрывайте ошибки проверки подписи за автоматическим переподключением.

События SSE типа `call` служат сигналом обновления. Они не заменяют REST-синхронизацию. После переподключения перечитайте состояние и сигналы; храните последний обработанный числовой `id`. Не записывайте SDP, ICE, `Envelope`, токены и TURN credentials в логи.

PushKit-регистрация: `PUT /api/v1/push/voip` принимает `{deviceToken, environment, deviceLabel?}`, где token — hex длиной 64–512 символов, environment — `production` или `sandbox`. `DELETE` принимает `{deviceToken}`. Нужна пользовательская сессия, API-токен не принимается. Лимит подписок — 16; превышение возвращает `409`. Используется APNs topic `${APNS_TOPIC}.voip`, push type `voip`, priority `10`, expiration `0`; VoIP token отличается от токена обычных уведомлений. Push содержит `callId`, `chatId`, `userId`, `callerId`, `callerName`, без SDP, ICE и аудио. Имя вызывающего доступно APNs. iOS должен своевременно передать входящий звонок CallKit.

Машиночитаемый контракт генерируется командой `python3 scripts/generate-api-docs.py` в [openapi.yaml](./openapi.yaml). HTML-справочник находится в [index.html](./index.html).

## ICE и TURN

`CALL_ICE_SERVERS_JSON` — JSON-массив объектов `{urls: string[], username?: string, credential?: string}`. Он имеет приоритет над `CALL_STUN_URLS`, который принимает STUN URL через запятую. Обе переменные передаются сервису `api` в `compose.yaml`.

Пример формы значения для защищённого серверного `.env` (замените все значения):

```dotenv
CALL_ICE_SERVERS_JSON='[{"urls":["stun:turn.example.com:3478"]},{"urls":["turn:turn.example.com:3478?transport=udp","turns:turn.example.com:5349?transport=tcp"],"username":"stix-call","credential":"REPLACE_WITH_RANDOM_PASSWORD"}]'
```

Если обе переменные пустые, backend использует публичные Google STUN. STUN без TURN может не соединить устройства в сетях операторов, за симметричным NAT и корпоративным firewall. Для production настройте аутентифицированный TURN и проверьте разговор между Wi-Fi и мобильной сетью, а также при блокировке UDP.

ICE credentials выдаются авторизованным участникам звонка: хранение в серверном `.env` не скрывает их от клиента. При статической учётной записи контролируйте relay-квоты и трафик, регулярно меняйте пароль одновременно в TURN и backend. Не включайте server credentials в приложение, frontend build, Git или публичную конфигурацию.

## Пример отдельного TURN-сервера

[turnserver.conf.example](../infra/coturn/turnserver.conf.example) — пример для coturn 4.6.x. Установите поддерживаемый пакет с исправлениями безопасности. Перед стартом замените домен и пути сертификатов, создайте пользователя через `turnadmin` в закрытой административной сессии; realm, username и пароль должны совпадать с ICE-конфигурацией backend. База пользователей и TLS-ключ доступны только служебному пользователю. Проверяйте отсутствие anonymous allocation, доступность TLS и журналы ошибок после запуска. Опции аутентификации, квот и запретов адресов сверены с [конфигурацией coturn](https://github.com/coturn/coturn/blob/4.6.3/examples/etc/turnserver.conf).

Публичный DNS домена TURN должен указывать прямо на relay-сервер. Откройте inbound `3478/UDP`, `3478/TCP`, `5349/TCP` и `49160–49259/UDP`, последний диапазон согласуйте с `min-port`/`max-port`. За NAT задайте `external-ip=PUBLIC_IP/PRIVATE_IP` и сохраните номера портов при перенаправлении. Вебовый HTTP reverse proxy не заменяет TURN listener. Для сетей, разрешающих только TLS/443, нужен отдельный IP/host с TURN TLS на 443 и соответствующий ICE URL.

Не добавляйте `no-auth`, `allow-loopback-peers` и разрешения на приватные peer IP. Пример запрещает внутренние сети, loopback, link-local, multicast; эти ограничения относятся к адресам назначения relay и не запрещают звонки пользователей, находящихся за NAT. Сертификат должен включать имя TURN-домена и обновляться с перезагрузкой coturn. Ёмкость диапазона портов и квоты в примере ограничены: расширяйте их после измерения нагрузки, вместе с firewall.

## Проверка перед выпуском

1. Применить миграцию звонков штатным запуском backend; проверить готовность и ограничения доступа к чужому чату.
2. Два тестовых аккаунта на реальных iPhone: входящий/исходящий звонок, отказ, отсутствие ответа, завершение с обеих сторон, микрофон, громкая связь.
3. Повтор запроса сигнала с тем же `clientId`, неверная подпись, сигнал от другого звонка и постороннего пользователя должны обрабатываться безопасно.
4. Проверить Wi-Fi ↔ LTE/5G и forced relay, потерю/возврат сети, блокировку экрана во время активного разговора, смену аккаунта.
5. Убедиться, что backend получает только зашифрованные SDP/ICE и не пишет их в журналы. Отдельно подтвердить, что неверные TURN credentials не дают relay allocation.

Локальная сборка и backend unit-тесты не заменяют эти проверки. Этот документ не свидетельствует о выполненном production-развёртывании, успешном звонке на двух устройствах или публикации в App Store.
