
От автора
В последнее время очень хочется мессенджер, в котором:
-
Нет центрального сервера
-
Сообщения шифруются end-to-end и не хранятся в открытом виде нигде
-
Любой при необходимости может поднять свой сервер легко и быстро и присоедениться к общей сети
-
Один сетевой стек вместо зоопарка протоколов
На Go есть библиотека libp2p, поддерживает работу с множеством транспортов, имеет встроенную аутентификацию пиров и предоставляет фундамент для децентрализованных P2P-сетей, которую крайне интересно было бы попробовать интегрировать в мобильное приложение в качестве транспорта для звонков и сообщений. Результатами попытки делюсь ниже.
Стек
Flutter отвечает за UI. Вся сетевая логика живёт в бинарнике, который компилируется в .dylib (macOS), .so (Android/Linux) или статическую библиотеку (iOS). Dart общается с Go через FFI (Foreign Function Interface) — прямые вызовы C-функций. Соединение между пирами может устанавливаться двумя путями: напрямую или через промежуточный узел — Circuit Relay v2. Последний необходим для обхода ограничений NAT и брандмауэров, когда прямой коннект между устройствами невозможен.

Главный и самый первый вопрос который встал у меня в начале разработки: как из Flutter-приложения вызывать Go-код? Лучше всего получилось через CGO. Go умеет компилироваться в C-совместимую shared library с экспортируемыми функциями.
Сборка Go → C-shared library
Android (so, нужен NDK):
CGO_ENABLED=1 GOOS=android GOARCH=arm64
CC=$ANDROID_NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android21-clang
go build -buildmode=c-shared
-o libp2p_network.so
./main.go
iOS (статическая библиотека .a через CGO):
CGO_ENABLED=1 GOOS=ios GOARCH=arm64
CC=$(xcrun --sdk iphoneos --find clang)
CGO_CFLAGS="-isysroot $(xcrun --sdk iphoneos --show-sdk-path) -arch arm64 -miphoneos-version-min=12.0"
CGO_LDFLAGS="-isysroot $(xcrun --sdk iphoneos --show-sdk-path) -arch arm64 -miphoneos-version-min=12.0"
go build -buildmode=c-archive
-o libp2p_network.a
./main.go
На выходе получаем бинарник с C-функциями, которые Dart может вызывать через dart:ffi.
На Android достаточно положить .so в jniLibs/arm64-v8a/, и Flutter подхватит его автоматически. На iOS — c-archive выдаёт .a + .h, которые линкуются статически в Xcode-проекте. Для универсальной библиотеки (device + simulator) собираются две .a под arm64 и x86_64, затем склеиваются через lipo -create.
FFI мост: Go → Dart
Go-сторона: экспорт C-функций
Каждая функция, которую нужно вызывать из Dart, помечается комментарием //export:
package main
/*
#include <stdlib.h>
*/
import "C"
import (
"sync"
"github.com/myapp/p2p"
)
var (
nodeInstance *p2p.Node
nodeMu sync.Mutex
)
//export StartNode
func StartNode(storagePath *C.char) *C.char {
nodeMu.Lock()
defer nodeMu.Unlock()
if nodeInstance != nil {
return C.CString(nodeInstance.GetPeerID())
}
path := C.GoString(storagePath)
node, err := p2p.NewNode(path)
if err != nil {
return C.CString("")
}
node.SetMessageHandler(func(msg *p2p.Message) {
// складываем в буфер для polling
})
if err := node.Start(); err != nil {
return C.CString("")
}
nodeInstance = node
return C.CString(node.GetPeerID())
}
//export SendMessage
func SendMessage(peerID, content, msgType, id *C.char) C.int {
nodeMu.Lock()
node := nodeInstance
nodeMu.Unlock()
if node == nil {
return -1
}
err := node.SendMessage(
C.GoString(peerID),
C.GoString(content),
C.GoString(msgType),
C.GoString(id),
)
if err != nil {
return -1
}
return 0
}
//export FreeString
func FreeString(s *C.char) {
C.free(unsafe.Pointer(s))
}
func main() {}
Важные моменты:
- C.GoString() копирует строку из C-памяти в Go — после этого Dart может освободить свою копию
- C.CString() выделяет память в C-хипе — Dart обязан вызвать FreeString после использования, иначе утечка
- Все экспортированные функции должны быть в пакете main
- func main() {} — обязательна, даже если пустая
Dart-сторона: загрузка и вызов
class P2PNode {
static DynamicLibrary? _lib;
void _loadLibrary() {
if (Platform.isAndroid) {
_lib = DynamicLibrary.open('libp2p_network.so');
} else if (Platform.isIOS) {
_lib = DynamicLibrary.process(); // статически слинковано
} else if (Platform.isMacOS) {
// ищем dylib в Frameworks бандла
final appDir = Platform.resolvedExecutable;
final frameworksDir = '${File(appDir).parent.path}/Frameworks';
_lib = DynamicLibrary.open('$frameworksDir/libp2p_network.dylib');
}
_startNode = _lib!.lookupFunction<
Pointer<Utf8> Function(Pointer<Utf8>), // C-сигнатура
Pointer<Utf8> Function(Pointer<Utf8>) // Dart-сигнатура
>('StartNode');
_sendMessage = _lib!.lookupFunction<
Int32 Function(Pointer<Utf8>, Pointer<Utf8>, Pointer<Utf8>, Pointer<Utf8>),
int Function(Pointer<Utf8>, Pointer<Utf8>, Pointer<Utf8>, Pointer<Utf8>)
>('SendMessage');
_freeString = _lib!.lookupFunction<
Void Function(Pointer<Utf8>),
void Function(Pointer<Utf8>)
>('FreeString');
}
}
Вызов Go-функции из Dart выглядит так:
Future<String> start() async {
final dir = await getApplicationDocumentsDirectory();
final pathPtr = dir.path.toNativeUtf8();
final resultPtr = _startNode(pathPtr);
final peerId = resultPtr.toDartString();
_freeString(resultPtr); // освобождаем C-память
calloc.free(pathPtr); // освобождаем Dart-память
return peerId;
}
Как работает libp2p-нода
Создание ноды — это конфигурирование libp2p хоста с нужными транспортами и протоколами:
func NewNode(storagePath string) (*Node, error) {
ctx, cancel := context.WithCancel(context.Background())
// Загружаем или генерируем Ed25519-ключ (это наш PeerID)
keyPath := filepath.Join(storagePath, "identity.key")
priv, _ := loadOrCreateKey(keyPath)
h, err := libp2p.New(
libp2p.Identity(priv),
libp2p.ListenAddrStrings(
"/ip4/0.0.0.0/tcp/0",
"/ip4/0.0.0.0/udp/0/quic-v1",
),
libp2p.EnableNATService(),
libp2p.EnableRelay(),
libp2p.NATPortMap(),
libp2p.EnableAutoRelayWithStaticRelays(relayAddrs),
)
// Kademlia DHT для discovery
kadDHT, _ := dht.New(ctx, h, dht.Mode(dht.ModeAutoServer))
node := &Node{host: h, dht: kadDHT, ctx: ctx}
// Регистрируем обработчик входящих сообщений
h.SetStreamHandler("/messaging/1.0.0", node.handleStream)
// Слушаем подключения/отключения пиров
h.Network().Notify(&network.NotifyBundle{
ConnectedF: func(n network.Network, c network.Conn) { ... },
DisconnectedF: func(n network.Network, c network.Conn) { ... },
})
return node, nil
}
Каждое устройство получает уникальный PeerID — это хеш публичного Ed25519-ключа. Ключ генерируется один раз и хранится на устройстве. PeerID — это ваш идентификатор в сети, аналог номера телефона, но без привязки к чему-либо.
Отправка сообщений
func (n *Node) SendMessage(peerIDStr, content, msgType, id string) error {
msg := Message{
ID: id,
From: n.host.ID().String(),
To: peerIDStr,
Content: content,
Type: msgType,
}
data, _ := json.Marshal(msg)
peerID, _ := peer.Decode(peerIDStr)
// Открываем stream к пиру (через relay если нужно)
s, err := n.host.NewStream(ctx, peerID, "/messaging/1.0.0")
s.Write(data)
s.Close()
return nil
}
Если пир онлайн — сообщение доставляется напрямую. Если оффлайн — шифруется и кладётся в хранилище серверной ноды до доставки(данную функцию можно отключить). При следующем подключении пир заберёт все накопленные сообщения.
Звонки
Для P2P-звонков 1 на 1 используется трехуровневая система транспорта, которая обеспечивает минимальную задержку, но гарантирует связь даже за жесткими NAT:
1. Прямой UDP-транспорт (Pion ICE): Основной и самый быстрый канал. При ответе на звонок ноды обмениваются ICE-кандидатами через обычные libp2p-сообщения (без громоздкого SDP). Устанавливается прямой UDP-канал, аудио-фреймы (Opus) шифруются кастомным симметричным ключом (на базе ключей libp2p) и летят напрямую.
2. libp2p DCUtR (Hole Punching): Если чистый UDP не пробивается, срабатывает механизм DCUtR (Direct Connection Upgrade through Relay). Пиры узнают свои внешние IP через Relay и пробивают прямое TCP/QUIC соединение на уровне libp2p.
3. libp2p stream через Relay (Фоллбек): Если оба клиента за симметричными NAT и прямое соединение невозможно, трафик бесшовно идет через серверную ноду по базовому libp2p-стриму (/call/1.0.0).
Отправка аудио
func (n *Node) SendAudio(data []byte) error {
call := n.activeCall
if call == nil || call.State != CallStateActive {
return fmt.Errorf("no active call")
}
// Фрейм: [0xFE][Len 2 bytes][Opus data]
packet := make([]byte, 1+2+len(data))
packet[0] = 0xFE
binary.BigEndian.PutUint16(packet[1:], uint16(len(data)))
copy(packet[3:], data)
call.Stream.Write(packet)
return nil
}
Прием аудио
func (n *Node) audioReadLoop() {
call := n.activeCall
for {
// Ждём sync byte
syncBuf := make([]byte, 1)
call.Stream.Read(syncBuf)
switch syncBuf[0] {
case 0xFE: // аудио
lenBuf := make([]byte, 2)
io.ReadFull(call.Stream, lenBuf)
frameLen := binary.BigEndian.Uint16(lenBuf)
opusData := make([]byte, frameLen)
io.ReadFull(call.Stream, opusData)
// Передаём Opus-фрейм обработчику
n.audioHandler(opusData)
}
}
}
Если устройства оффлайн.
Поскольку нет классического центрального сервера, возникает резонный вопрос: как получить сообщение, если приложение выгружено из памяти или телефон заблокирован?
В этом случае на помощь приходят Push-уведомления (APNs для iOS, FCM для Android), но с важнейшей оговоркой ради сохранения E2EE и приватности: в самом пуше не передаётся ничего важного. В нём нет ни текста сообщения, ни ключей, ни даже реального отправителя. Это просто "слепой" триггер (silent/data push), который служит только для одной цели — разбудить устройство.
Механика работы выглядит так:
-
На телефон прилетает пуш-сигнал.
-
Операционная система на короткое время будит приложение в фоновом режиме.
-
В фоне стартует наша Go-нода.
-
Нода подключается к сети и скачивает все накопившиеся зашифрованные пакеты.
-
Расшифровка происходит строго локально, после чего приложение само формирует и показывает пользователю полноценное локальное уведомление с текстом сообщения.
Таким образом, получаем удобство классических мессенджеров, не компрометируя безопасность передаваемых данных.
Шифрование: E2EE как в Signal и WhatsApp
Безопасность — это фундамент любого современного мессенджера. Не стал(да и не смог бы быстро) изобретать велосипед (свою криптографию) или ограничиваться простым статичным шифрованием. В проекте реализовано полноценное End-to-End шифрование (E2EE) с Perfect Forward Secrecy (PFS) и Post-Compromise Security (PCS).
Архитектура шифрования разделена на два современных стандарта: один для личных чатов, другой — для групповых.
1. Личные чаты (1 на 1): Double Ratchet
Для приватных переписок используется алгоритм Double Ratchet (тот самый, что лежит в основе протокола Signal). Использую реализацию status-im/doubleratchet.
Как это работает:
1. Инициализация (X3DH): При первом контакте пиры используют свои статические ключи libp2p (Ed25519 конвертируются в X25519) для выполнения Diffie-Hellman и получения общего Root Key.
2. Симметричный храповик (Symmetric Ratchet): Каждое отправленное сообщение прокручивает цепочку ключей (KDF) через хеш-функцию. Ключ от каждого сообщения уникален. Если хакер перехватит ключ от сообщения №5, он не сможет прочитать сообщения №1–4 (Forward Secrecy).
3. Асимметричный храповик (DH Ratchet): Периодически к сообщениям прикрепляются новые эфемерные публичные ключи (Diffie-Hellman). При получении такого ключа генерируется новый Root Key. Это значит, что если устройство было скомпрометировано (ключи утекли), но потом хакер потерял к нему доступ — после пары новых сообщений ключи обновятся, и хакер снова не сможет читать переписку (Post-Compromise Security).
Даже если сообщение доставляется в оффлайне (через Relay-сервер), оно зашифровано уникальным ключом сессии. Relay видит только нечитаемый бинарный мусор.
2. Групповые чаты: Messaging Layer Security (MLS)
Double Ratchet отлично работает для двух человек, но в группах он превращается в кошмар: чтобы отправить сообщение в группу из 50 человек, нужно зашифровать его 50 раз разными ключами (Sender Keys). Это убивает батарею и сеть.
Поэтому для групп внедрил MLS (Messaging Layer Security, RFC 9420) — новейший стандарт IETF для группового E2EE. Использую библиотеку mls-go.
В чем магия MLS:
Вместо того чтобы шифровать сообщение для каждого участника отдельно, MLS строит бинарное дерево ключей (Ratchet Tree).
* Группа имеет один общий симметричный ключ для шифрования сообщений.
* При добавлении или удалении участника дерево перестраивается (отправляется Commit и Welcome сообщения), и генерируется новая эпоха (Epoch) с новым общим ключом.
* Вычислительная сложность добавления/удаления участника и обновления ключей логарифмическая O(log N), а не линейная O(N).
Итог: Группы на сотни человек шифруются так же быстро и с такими же гарантиями безопасности (PFS и PCS), как и личные чаты. Relay-сервер просто рассылает (fan-out) один зашифрованный пакет всем участникам группы, не имея доступа к ключам дерева.
Что дальше
Если увижу заинтересованность сообщества, планирую развивать проект. В первую очередь:
-
Федерация серверных нод — любой сможет поднять свою, DHT для автоматического обнаружения. В таком сценарии можно полностью изолировать свои сообщения, как в Matrix.
-
Полный P2P режим. Как у Jami и ему подобных
-
Групповые звонки.
-
Open-source — клиентский пакет (Go + Dart)
Если хотите попробовать результата данного эксперимента - App Store. Чуть позже выложу в гугл маркет. Всем спасибо за внимание!
Автор: callsauI
