Будучи поклонником suckless философии, минимализма и простоты в программном обеспечении, я не нахожу простых и маленьких VoIP решений. Программ связанных с VoIP очень много. Но это либо колоссальные системы для построения SIP-based экосистемы, либо что-то из мира WebRTC. Особняком выделяю Mumble -- популярное решение среди геймеров: централизованный сервер, указываешь его адрес и вот уже слышишь в наушниках другого человека. А нет ли чего ещё попроще?


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

Готовые решения

SIP-related решения я не рассматриваю: они для гораздо более масштабной задачи. Может быть какой-нибудь baresip поднимается быстро и легко, но для него же ещё нужен и сервер. Да и сборка CMake-ом точно не может относится к suckless решению. Порог входа и минимальный набор понятий для погружения в эту экосистему выходит за рамки простоты. Я ничего не имею против них, но для такой простой задачи (грубо говоря, указать клиенту два аргумента) они явно избыточны.

WebRTC решения исключены. Насколько мне известно, по сути в мире есть всего 2-3 свободных реализации WebRTC стэка: неимовернейшая по количеству кода и сложности C++ библиотека, а также написанная на Go, содержащая массу кода, как минимум, из-за огромных размеров самой WebRTC спецификации. Я считаю безумием использовать небезопасные среды запуска произвольно скачиваемого кода из Интернета (то бишь, современные Web-броузеры). Таким же безумием является и использование WebRTC монстров для решения такой простой задачи. Как правило, WebRTC решения запускаются внутри этих броузеров: а значит всё будет тормозить, отъедать кучу ресурсов, компрометировать безопасность компьютера. Плюс мой опыт показывал, что не всегда произвольные броузеры между собой могут работать: Chromium и Firefox не факт что смогут связаться между собой.

Если убрать необходимость работы за NAT (ну сколько можно, 2024-ый год, кто-то всё упорно не хочет на IPv6 переходить?), оставить только один аудиокодек, то львиная доля всей сложности уже убралась бы. Если у кого-то ещё нет IPv6 среди коллег, то всегда можно поднять VPN-ы, чтобы получить хотя бы виртуальную сеть с прямой связанностью между компьютерами.

Видел какие-то GNU VoIP решения, но они либо плохо работали не на GNU/Linux (я использую ОС получше), либо были написаны на Java и тоже потребляли тьму ресурсов.

Mumble удовлетворителен почти всем. Сервер поднимается почти без какой-либо настройки. В клиенте достаточно только адрес и пароль указать для работы. Но их родной клиент написан на Qt, что тоже довольно много требует зависимостей и места на диске для сборки. Это конечно несравнимо с Web/WebRTC, но всё же далеко до suckless. Плюс это GUI программа, что мне, человеку почти не покидающему терминал и командную строку, не шибко удобно. Видел неофициальные клиент и сервер Mumble переписанные на Go. Но оба работали не очень хорошо, пришлось забыть. Возможно под GNU/Linux они бы вели себя лучше/стабильнее, но я использую

Нельзя ли за пару дней написать удовлетворительное решение? Попробуем! Сразу отмечу, что о поддержке проприетарных несвободных закрытых операционных речи не может идти.

Запись аудио

Первым вопросом будет: как записывать данные с микрофона и проигрывать несколько звуковых потоков (от каждого участника)? Есть масса звуковых API, но они либо не кроссплатформенны (в FreeBSD, NetBSD, OpenBSD -- свои системы, в GNU/Linux вообще лютый зоопарк), либо для них нет binding-ов для языка Go. Что мешает следовать Unix-way и просто использовать утилиты типа play/rec из состава SoX проекта? Они сами знают как общаться со звуковой подсистемой конкретной ОС, а данные от/для них мы можем передавать через stdin/stdout онных. Запуск нескольких play-процессов, как правило, не страшен -- звуковая подсистема самой ОС без проблем должна мочь их параллельно воспроизводить. Вместо SoX можно и FFmpeg какой-нибудь использовать без проблем.

Так как предполагается, что общение будет происходить в комфортных условиях за ПК с достаточно хорошим доступом к Интернету, то нет жёстких ограничений на считанные килобиты для аудиопотока. Но и разбрасываться в пустую пропускной способностью канала тоже не стоит. Отличнейшим (лучшим?) выбором будет аудиокодек Opus. 24Kbps более чем достаточно для очень хорошей передачи речи.

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

Если мы планируем передавать 48кГц 16-бит 1-канальный звук, то из stdout rec команды мы должны вычитывать 48000220/1000=1920 байт. Передаём в функцию кодирования Opus -- она их заменит на несколько десятков байт.

 +---------------1920 байт--------------+
/                                        \
+-----------------------------------------+
|                    PCM                  |
+-----------------------------------------+
                      |
                      v
                  +------+
                  | Opus |
                  +------+

Передача аудио

Как передавать эти сжатые данные по сети? Использование TCP исключено: нам не нужна гарантированная доставка, мы должны как можно быстрее подавать в аудиотракт отправляемые пакеты. Иначе будут появляться всё возрастающие задержки между собеседниками. UDP тут идеален. А может быть стоит смотреть в сторону RTP, специально сделанного для этих задач? А зачем? Совместимость с разными существующими системами нам блюсти не надо, а ценностью его нескольких полей заголовка не являются.

Просто передать sample сжатых данных в датаграмме нельзя -- пакет может пропасть, может прийти позже следующего. А Opus кодек имеет состояние, которое можно попортить переупорядоченными пакетами. Поэтому, как минимум, необходимо добавить порядковый номер пакетов, чтобы понимать, что возникала пропажа пакетов или было переупорядочивание. Добавляем 24-бита (3 байта) перед данными. Учитывая, что пакет отправляется каждые 20мс, то это 50 пакетов в секунду. 24-бит хватит не на один день беспрерывного общения.

  24b
+-----+--------------...-+
| CTR |        Opus      |
+-----+--------------...-+

В Opus есть такая штука, как PLC (Packet Loss Concealment). Если мы понимаем, что у нас произошла пропажа пакета, то мы явно об этом говорим Opus-у (передаём пустой sample в функцию декодирования) и он сгенерирует некий фиктивный звуковой sample на основе предыдущего состояния. Хотя бы не будет тишины. Ещё есть DTX (discontinuous transmission), где он может определять наличие тишины и вообще не отправлять пакет с данными (функция кодирования вернёт пустоту).

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

  8b       24b
+-----+-----------+----------------------...-+
| SID |    CTR    |           Opus           |
+-----+-----------+----------------------...-+

В идеале, если мы хотим понимать был ли пакет задержан, какой имеется jitter, то нужно добавить ещё 24-бита с монотонно возрастающим порядковым номером аудио sample полученного с микрофона. С микрофона данные поступают постоянным потоком: номер 20мс sample, можно сказать, является абсолютной мерой времени ("абсолютный" счётчик, aCTR). Но не каждый прочитанный sample с микрофона будет отправлен в сеть из-за DTX. Без дополнительной передачи абсолютного времени аудиопотока мы не знаем была ли сейчас тишина или sample просто доставился с задержкой. Но так как нам не нужно синхронизировать разные потоки между собой, то можно и без этого было бы обойтись.

  8b       24b         24b
+-----+-----------+-----------+----------------------...-+
| SID |    CTR    |   aCTR    |           Opus           |
+-----+-----------+-----------+----------------------...-+

Защита аудио

Как защищать наш пакет? Ведь мы же не хотим, чтобы нас прослушивали? Самое простое решение: использовать один чётко заданный шифр, не париться с каким-либо согласованием и выбором онного. Популярный ChaCha20 и имеет крайне высокий порог безопасности и очень быстро работает даже без аппаратного ускорения (в отличии от, тоже популярного, AES). При его использовании, критично, чтобы для каждого пакета использовалось никогда не повторяющее значение (для одного ключа) nonce. А оно у нас и так уже есть и передаётся -- наш порядковый номер пакета.

Безусловно, пакет должен быть аутентифицирован. Совместно с ChaCha20 почти всегда применяется Poly1305 одноразовый MAC. Сейчас будет некая экономия на копейках, но всё же: его 128-бит значение можно считать избыточным для данной задачи. Время жизни каждого пакета крайне мало и не нужно иметь высоченный уровень безопасности его аутентичности. Кроме того, ключ Poly1305 генерируется для каждого пакета следующим образом: из ChaCha20, использующегося в качестве генератора псевдослучайных данных с размером блока 64-байта, извлекаются 64-байта (один блок), где первые 32-байта используются в качестве ключа Poly1305. Далее извлекается второй блок, который XOR-ится с полезной нагрузкой нашего пакета, тем самым шифруя его. Но особенность наших пакетов в том, что в них зачастую передаётся всего 20-40 байт Opus. Выходит, что из ChaCha20 мы должны извлечь 128-байт данных, чтобы разобраться с этими 20-40 байт Opus. Дороговато.

Для аутентификации коротких сообщений есть и специализированные функции, типа SipHash. Он (SipHash-2-4, рекомендуемый авторами вариант) работает чуть ли не на порядок быстрее чем весь цикл для Poly1305. По умолчанию он создаёт 64-бит MAC значения, которых нам вполне достаточно. Поэтому вместо Poly1305, используем SipHash24, ускоряя и шифрование (сразу же первый блок ChaCha20 можно использовать), и аутентификацию, и сокращая избыточный размер MAC.

Таким образом, накладные расходы составляют 8 байт UDP заголовка, 1+3+3=7 байт нашего заголовка, 8 байт SipHash24 MAC. Меньше чем было бы у SRTP. Впрочем, это были бы всё-равно копейки относительно 40-байтного IPv6 заголовка.

    +-------------------  SipHash24 -----------------+
   /                                                  \
  /                             +------ ChaCha20 ------+
 /                             /                        \
+-----+-----------+-----------+----------------------...-+-----+
| SID |    CTR    |   aCTR    |           Opus           | MAC |
+-----+-----------+-----------+----------------------...-+-----+

Сервер

Какова роль сервера? Подключающийся к нему клиент должен быть аутентифицирован. Каким-то образом должны быть выработаны ключи шифрования для нашего ChaCha20+SipHash24 алгоритма. Должны быть оповещены все остальные подключённые клиенты о появлении нового участника. Все UDP пакеты отправляются на сервер, а затем он их просто копирует остальным участникам.

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

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

Защита сервера

Очевидным решением (так как в Go есть собственная отличная реализация) было бы использовать TLS для защиты этого управляющего TCP канала. В TLS есть возможность аутентификации сервера и клиента по асимметричным ключам. Но, заставлять использовать клиентские TLS сертификаты -- выглядит не очень просто. Работать будет, безопасно, но было бы достаточно ввода просто долговременного пароля какого-нибудь, отправленного через защищённую почту или IM. Можно бы было задействовать механизм PSK (pre-shared key) в TLS 1.3, используя в нём пароль, но Go API для TLS не позволяет этого сделать. Можно аутентифицировать по TLS только сервер, а клиента уже по парольному протоколу внутри TLS сессии.

Но нельзя ли suckless принцип применить и к защите TCP соединения? Все версии TLS до 1.3 -- ужасны и по архитектуре и по громоздкости протокола. TLS 1.3 уже хорош, но у нас остаётся необходимость работы с ASN.1 для разборки и проверки X.509 сертификата сервера. А давайте сделаем свой протокол, без всего этого X.509 ада порождённого коммитетами!

Noise

Конечно же, разрабатывать криптографический протокол с нуля не стоит. Воспользуемся конструктором для построения онных: Noise Protocol Framework. Для начала, мы должны определиться с шаблоном (pattern-ом) подходящим для нашей задачи. Просто навсего, ищем подходящее значение двух буковок pattern-а: как и когда передаётся ключ инициатора (клиента) и ключ ответчика. Мы можем считать факт знания публичного ключа сервера -- тем самым аутентифицирующим клиентов паролем (shared knowledge). То есть, явно публичный ключ сервера в нашем TCP протоколе не передаётся. Аутентификация клиента будет производится просто по факту успешного установления соединения, без явного ключа аутентификации -- если клиент не знает долгоживущий публичный ключ сервера, то он не сможет произвести требуемые криптографические вычисления. Ключ предполагается распространять среди коллег аналогично паролю. Таким образом, нам подходит Noise-NK pattern. Приятный ещё и тем, что в нём всего один round-trip.

<- s
...
-> e, es
<- e, ee

Этот pattern стоит читать так:

  • каким-то образом, заранее, мы передаём инициатору долгоживущий статический DH-ключ ("s") ответчика;

  • инициатор генерирует эфемерный DH-ключ ("e"), добавляет к отправляемому сообщению;

  • инициатор вычисляет DH между нашим эфемерным и статическим ответчика ("es");

  • после чего, мы добавляем произвольную полезную нагрузку к сообщению, ведь после "es" у нас уже имеется ключевой материал из которого выработаны ключи AEAD-шифрования. Даже если нагрузки нет, будет пустой шифротекст, но не пустой MAC;

  • ответчик, после приёма, вычисления "es" со своей стороны, проверки аутентичности и дешифрования полезной нагрузки, генерирует свой эфемерный DH-ключ и добавляет к отправляемому сообщению;

  • ответчик вычисляет DH от двух эфемерных ключей ("ee"). Всюду используется key ratcheting, ключевой материал и ключи постоянно обновляются;

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

Далее необходимо выбрать набор алгоритмов для Noise защищённого соединения. Раз мы уже используем алгоритмы созданные Daniel J. Bernstein (DJB), то выбор очевиден: curve25519 для согласования ключей, ChaCha20-Poly1305 для AEAD-шифрования, BLAKE2s в качестве хэш-функции.

Для Go есть отличная реализация Noise framework в виде github.com/flynn/noise библиотеки. Использовать её очень легко и просто. Первым делом, на стороне клиента, мы должны задать все начальные данные и создать первое сообщение рукопожатия для отправки:

hs, err := noise.NewHandshakeState(noise.Config{
    CipherSuite: noise.NewCipherSuite(
        noise.DH25519,
        noise.CipherChaChaPoly,
        noise.HashBLAKE2s,
    ),
    Pattern:     noise.HandshakeNK,
    Initiator:   true,
    PeerStatic:  []byte(srvPub),
})
buf, _, _, err := hs.WriteMessage(nil, []byte(appPayload))

buf будет содержать некое сообщение рукопожатия. На стороне сервера мы инициализируем состояние Noise и обрабатываем этот пакет:

hs, err := noise.NewHandshakeState(noise.Config{
    CipherSuite: noise.NewCipherSuite(
        noise.DH25519,
        noise.CipherChaChaPoly,
        noise.HashBLAKE2s,
    ),
    Pattern:       noise.HandshakeNK,
    Initiator:     false,
    StaticKeypair: noise.DHKey{Private: []byte(Prv), Public: []byte(Pub)},
})
appPayload, _, _, err := hs.ReadMessage(nil, buf)
buf, _, _, err = hs.WriteMessage(nil, []byte(appResponse))

Отправляем buf, где содержится второе сообщение рукопожатия. На клиенте после его успешной обработки:

buf, tx, rx, err := hs.ReadMessage(nil, buf)

tx и rx содержат объекты позволяющие шифровать и дешифровать произвольные пакеты данных. tx.Encrypt(data) позволяет зашифровать данные, которые rx.Decrypt(buf) на стороне сервера дешифрует.

Вот это и вся работа с Noise framework-ом! Ну хорошо, ещё нужно написать утилиту генерирования ключевых пар curve25519:

kp, err := noise.DH25519.GenerateKeypair(rand.Reader)
os.Stdout.Write(kp.Private[:])
os.Stdout.Write(kp.Public[:])
...
Prv, Pub := raw[:len(raw)/2], raw[len(raw)/2:]

Превосходно защищённый канал связи с аутентификацией сервера. Общее количество задействованного кода для всего этого во много раз (порядки, если учитывать необходимость декодировать ASN.1 X.509 сертификаты) меньше любой TLS 1.3 реализации. Плюс всего одна приёмопередача. Если использовать TCP Fast Open, то сообщение рукопожатия от клиента можно бы было вообще в TCP SYN пакете отослать, как и ответ сервера в TCP ACK.

netstring

Но пока есть засада: Noise не диктует никаких форматов и правил сериализации данных. В TCP передаются потоки байтов. Поэтому нам нужно как-то понимать где находятся границы Noise пакетов. Хоть 16-бит длину добавлять перед каждым пакетом -- никаких проблем. Но я использовал решение от всё того же DJB: netstring. Формат сериализации netstring очень прост и приятен человекочитаемостью:

def NS(data): return printf("%d:%s,", len(data), data)

Запятая в конце -- не опечатка. К тому же очень удобная возможность заменить её на нулевой байт, временами упрощая работу в Си-программах со строками.

Сигнальный протокол

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

Если же ошибок нет, то сервер отправляет NS(NS("COOKIE") || NS(RANDOM)) сообщение, где RANDOM это 128-бит случайное число. Клиент должен будет отправить этот RANDOM в UDP пакете на сервер. Это нужно для:

  • подтверждения успешно согласованной Noise сессии, что мы смогли корректно дешифровать пакет от сервера;

  • неявной передачи UDP адреса клиента, через который будет проходить аудио поток. Хоть про устаревший IPv4 мир с его NAT-ом я думать не собираюсь, но это также поможет и в нём, ведь после прохождения NAT-а, наш UDP порт, с точки зрения сервера, может поменяться неизвестным нам образом;

  • UDP hole punching в случае stateful firewall (или NAT) на нашем пути, разрешающим входящий трафик, только если от нас шёл исходящий на том же порту.

Так как пакет может потеряться, то мы отсылаем его раз в секунду, а сервер ждёт в течении какого-то времени. После успешного получения cookie, он отвечает NS(NS("SID") || NS(ID)) сообщением, где ID это один байт идентифицирующий нас, который будет первым байтом наших зашифрованных Opus пакетов.

S <- C : e, es, NS("USERNAME")
S -> C : e, ee, NS(NS("COOKIE") || NS(RANDOM))
S <- C : UDP(RANDOM)
S -> C : NS(NS("SID") || NS(ID))

Остаётся откуда-то взять ключевой материал для генерирования ключей шифрования/аутентификации Opus пакетов. В Noise состоянии есть, так называемый, binding value сессии, который можно использовать для этой цели. Его размер всего 256-бит, а нам нужно два 256-бит ключа для ChaCha20 и SipHash24. Часто применяют HKDF-Expand функцию для "расширения" ключевого материала до нужных значений. У нас вовсю используется BLAKE2s хэш-функция. Существует BLAKE2X модификация, превращающая фиксированную длину выхода хэша в строку произвольной длины. Это и используем для расширения нашего binding value до нужных размеров, нарезая получившиеся данные на ключи.

keys := make([]byte, chacha20.KeySize+poly1305.KeySize)
xof, err := blake2s.NewXOF(len(keys), nil)
xof.Write(hs.ChannelBinding())
_, err = io.ReadFull(xof, keys)
keyCiph, keyMAC = keys[:chacha20.KeySize], keys[chacha20.KeySize:]

После всех этих процедур, по Noise-защищённому TCP каналу периодически проходят PING/PONG пакеты для проверки жизнеспособности клиентов. По нему отправляются события типа NS(NS("ADD") || NS(SID) || NS(USERNAME) || NS(KEY)) для оповещения клиентов о появлении нового участника, с заданным именем, SID-ом и ключами защиты. NS(NS("DEL") || NS(SID)) для оповещения об отключении клиента.

S <- C : NS(NS("PING"))
S -> C : NS(NS("PONG"))
S <> C : ...
S -> C : NS(NS("ADD") || NS(SID) || NS(USERNAME) || NS(KEY))
S <> C : ...
S -> C : NS(NS("DEL") || NS(SID))
S <> C : ...

Принимаемый сервером UDP трафик, просто переотправляется остальным клиентам. SID-ы и ключи они знают -- смогут дешифровать. В виду дешевизны SipHash24 вычисления, сервер может проверять MAC сообщений, чтобы не рассылать априори "битый" трафик.

Так как клиент может вообще не отправлять голосовой трафик (молчать), то раз в секунду шлёт пакет с пустой полезной нагрузкой, исключительно ради поддержания state в firewall/NAT.

UI

Собственно, вот и всё решение готово! Остаётся к нему добавить только пользовательский интерфейс. Для VoIP смысла в GUI, конечно же, нет. Но было бы полезно иметь возможность включать/выключать mute комбинацией клавиш из любого места. Простой, одобренный suckless, sxhkd демон позволяет вызывать произвольную команду на X11-события. В текущей реализации есть возможность создания FIFO файла, читая из которого, будет меняться состояние mute. Таким образом, например конфигурация sxhkd:

XF86AudioMute
    herbe $(cat /path/to/mute-toggle.fifo)

при нажатии на "mute" мультимедиа клавишу, будет читать FIFO файл, выводя текущее значение mute, которое будет показано всплывающим окном на экране через suckless-одобренную herbe утилиту.

Заключение

На результат всего что получилось можно посмотреть тут. Как и все мои проекты, VoRS является полностью свободным ПО, распространяемым на условиях AGPLv3.

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


  1. Lazhu
    20.05.2024 11:34
    +1

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

    https://discord.com/


  1. Tinkz
    20.05.2024 11:34
    +1

    минимализма и простоты в программном обеспечении

    всегда можно поднять VPN-ы

    в моём представлении простоты одно с другим не клеится


    1. kmeaw
      20.05.2024 11:34
      +1

      Альтернативу в виде встраивания алгоритмов обхода NAT в каждую программу сложно назвать простой.

      В случае с VPN задача решается один раз, и можно использовать любые программы, которые умеют общаться по IP.


  1. x-tray
    20.05.2024 11:34

    Автор не осилил NAT (stun turn) , и предлагает пользователю перед использованием поднять VPN ,.... ну как бы такое себе предложение ....