Назад к блогу
Security10 min read

Процесс сопряжения

В этой статье объясняется, как два устройства PlainApp впервые устанавливают доверие — как они обнаруживают друг друга, обмениваются ключами и приходят к общему транспортному ключу ChaCha20, которым впоследствии шифруется каждое сообщение чата, передача файла и presence-пинг. Архитектура чата и каналов, использующая этот ключ, рассмотрена в отдельной статье «Chat Architecture».

Содержание

Зачем нужно сопряжение

В PlainApp нет центрального сервера учётных записей. Поэтому перед началом связи устройства должны ответить на два вопроса:

  1. «Кто ты?» — каждое устройство при первом запуске генерирует стабильный clientId (13-символьный идентификатор, производный от ключевого материала Ed25519). Это единственный идентификатор, используемый для маршрутизации, presence и членства в каналах.
  2. «Могу ли я тебе доверять?» — без сервера, подтверждающего личность, единственный способ убедиться, что пир действительно тот, за кого себя выдаёт, — чтобы человек подтвердил сопряжение на обоих устройствах, а протокол проверил криптографические подписи.

Сопряжение создаёт единственный артефакт: строку DPeer в базе данных со status="paired", ключом ChaCha20 key (общий транспортный секрет) и Ed25519 public_key пира (для проверки будущих подписей сообщений). Все последующие протоколы в подсистеме чата предполагают наличие этих двух полей.

Diagram 1
1

Модель доверия и криптография {#trust-model--cryptography}

Сопряжение использует два независимых криптографических примитива:

ПримитивНазначениеЖизненный цикл
Ed25519 (подпись)Аутентификация запроса и ответа сопряжения. Проверяет «это действительно пришло от устройства, утверждающего отправку» и привязывает метку времени для защиты от повторного воспроизведения.Ключ подписи является долгосрочным ключом идентификации устройства. Его открытая половина сохраняется как DPeer.public_key и впоследствии используется в PeerChatParser.decrypt для проверки подписи каждого сообщения чата.
X25519-style ECDH (согласование ключа)Выработка общего секрета, который становится транспортным ключом ChaCha20. Оба устройства вычисляют один и тот же секрет, никогда его не передавая.Эфемерная пара ключей генерируется на каждую сессию сопряжения и уничтожается сразу после вычисления общего ключа. Полученный 32-байтовый секрет сохраняется как DPeer.key и используется в течение всего срока сопряжения.

Здесь нет PIN-кода, QR-кода или out-of-band кода. Доверие устанавливается через:

  1. Человек нажимает Accept на устройстве-ответчике (пользователь утверждает «да, это устройство, с которым я хочу сопрячься»).
  2. Обе стороны проверяют подпись Ed25519 другой стороны на запросе/ответе (доказывая, что ответчик общается с тем же устройством, которое инициировало сессию, и наоборот).
  3. Окно метки времени ±5 мин на обоих сообщениях (защита от повторного воспроизведения старого перехваченного рукопожатия).

Асимметрия важна: одного лишь подтверждения человеком было бы недостаточно — оно уязвимо для атаки «человек посередине» (злоумышленник мог бы сопрячься с каждой стороной отдельно). Подпись Ed25519 на открытом ключе ECDH это предотвращает — ответчик проверяет, что запрос подписан тем же ключом Ed25519, который инициировал сессию, и наоборот, поэтому MITM не может прозрачно подставить собственный ключ ECDH, не контролируя при этом долгосрочный ключ подписи.

Карта компонентов

Весь код сопряжения находится в пакете discover/ (а не в chat/peer/pair/):

Diagram 2
2

Расположение файлов

КомпонентПуть (внутри shared/src/commonMain/kotlin/com/ismartcoding/plain/)
LANDiscoverManagerdiscover/LANDiscoverManager.kt
PairingCorediscover/PairingCore.kt
PairingInitiatordiscover/PairingInitiator.kt
PairingResponderdiscover/PairingResponder.kt
PairingSecuritydiscover/PairingSecurity.kt
PairingSessionStorediscover/PairingSessionStore.kt
PairingPeerStorediscover/PairingPeerStore.kt
PairingMessengerdiscover/PairingMessenger.kt

Фаза обнаружения

Перед сопряжением устройства должны найти друг друга. LANDiscoverManager работает непрерывно с момента запуска приложения:

Diagram 3
3

Зачем направленное обнаружение шифруется

Широковещательный DISCOVER не раскрывает ничего секретного (только fromId=clientId), поэтому его видимость любым устройством в LAN допустима. Направленный же вариант используется, когда одно устройство уже знает clientId другого (например, они сопряжены, но IP-адрес пира изменился) и хочет его «разбудить». Шифрование целевого clientId общим ключом пира означает:

  • Правильный пир может расшифровать toId, узнать себя и ответить.
  • Все остальные устройства в LAN видят только шифротекст — они не могут перечислить, какие clientId отправитель пытается достичь.

Это небольшое, но реальное свойство конфиденциальности: пассивные наблюдатели LAN не могут построить граф, кто с кем сопряжён.

Флаги Aware в ответе

DISCOVER_REPLY несёт awareSupported и awareRunning. Они не сохраняются в базе данных — они хранятся в памяти в PeerCacher и обновляются при каждом ответе (а также из serviceData BLE scan-response). Транспортный слой обращается к ним, чтобы решить, пытаться ли установить соединение Wi-Fi Aware или сразу перейти к BLE.

Последовательность сопряжения (сценарий без ошибок)

Полный поток, когда оба устройства находятся в одной LAN и пользователь принимает сопряжение:

Diagram 4
4

Почему обе стороны сохраняют пир независимо

Заметьте, что и инициатор (шаг 9), и ответчик (шаг 7) вызывают PairingPeerStore.save(...) для другого устройства. Это сделано намеренно: каждое устройство получает строку DPeer, ключом которой является clientId другого, со собственной копией общего ключа ChaCha20 и открытым ключом Ed25519 другого. Центрального реестра нет — сопряжение симметрично и самодостаточно.

Почему ответчик вычисляет ключ первым

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

Детали обмена ключами

Криптографическое ядро сопряжения — стандартное согласование ключа X25519-style ECDH, поверх которого накладывается подпись Ed25519 для аутентификации.

Diagram 5
5

Что именно защищает подпись

Подписанные данные (toSignatureData()) — это каноническая конкатенация стабильных полей запроса: fromId, fromName, port, deviceType, ecdhPublicKey, signaturePublicKey, timestamp и ips. Подписывая ecdhPublicKey вместе с долгосрочным signaturePublicKey, протокол привязывает эфемерный ключ к идентификации устройства. Злоумышленник не может подменить открытый ключ ECDH в пути, не нарушив подпись, — и не может подделать подпись, не контролируя долгосрочный ключ Ed25519.

Именно это и побеждает атаку «человек посередине»: даже если злоумышленник ретранслирует каждый пакет между двумя устройствами, он не может прочитать зашифрованный трафик (поскольку не имеет ни одного из ECDH-приватных ключей сторон) и не может подставить собственные ключи ECDH (поскольку подписи нарушатся).

Поток Accept/Decline на стороне ответчика

На стороне ответчика при поступлении PAIR_REQUEST отображается диалог. Пользователь может либо принять, либо отклонить запрос.

Diagram 6
6

Почему ответчик немедленно генерирует PairingSuccessEvent при принятии

acceptPairingRequest ответчика вызывает PairingPeerStore.save(...) и генерирует PairingSuccessEvent до отправки ответа. Это сделано намеренно: если ответ так и не достигнет инициатора (сетевой сбой), ответчик всё равно остаётся сопряжённым — при следующей попытке сопряжения со стороны инициатора уже существующая строка DPeer ответчика будет подобрана системой presence. Инициатор просто повторяет попытку; ответчику повторно подтверждать не нужно.

Поток отмены

Любая сторона может отменить выполняемое сопряжение.

Diagram 7
7

Заметьте, что DPairingCancel отправляется только через LAN unicast (инициатор уже имеет IP ответчика из фазы обнаружения), тогда как ответ Decline отправляется и через LAN, и через BLE, поскольку ответчик не может быть уверен, через какой транспорт инициатор достижим.

Доставка по двум каналам (LAN + BLE) {#dual-channel-delivery-lan--ble}

Когда ответчик отправляет DPairingResponse, он делает это одновременно и через LAN, и через BLE. Инициатор принимает первую копию и тихо отбрасывает дубликат.

Diagram 8
8

Зачем нужен BlePairingSessionStore

Когда PAIR_REQUEST приходит через BLE, у ответчика нет LAN-IP для инициатора — только его BLE MAC-адрес. BlePairingSessionStore отображает peerId → MAC, чтобы при необходимости направить ответ обратно через BLE. Это небольшая эфемерная таблица в памяти, заполняемая только для запросов, поступивших через BLE, и очищаемая после отправки ответа.

Хранение сессии и пира {#session--peer-storage}

В сопряжении участвуют два хранилища с совершенно разными сроками жизни:

Diagram 9
9

Почему clientId — единственный сохраняемый идентификатор

Android рандомизирует BLE MAC-адрес при каждом соединении, поэтому хранить его бессмысленно. clientId выводится из долгосрочного ключевого материала Ed25519 устройства, поэтому он:

  • Стабилен при переустановке приложения (ключ хранится в платформенном keystore).
  • Самоаутентифицирующийся — любой, заявляющий clientId, должен доказать, что владеет соответствующим приватным ключом Ed25519 (проверяется на каждом подписанном сообщении).
  • Конфиденциальный — только 8-байтовый префикс SHA-256 (shortId) транслируется через BLE для обнаружения; полный clientId раскрывается только устройствам, с которыми вы реально сопрягаетесь.

Свойства безопасности {#security-properties}

СвойствоКак достигается
КонфиденциальностьВесь транспорт шифруется ChaCha20 с использованием общего ключа, выведенного через ECDH. После сопряжения ключ никогда не покидает два устройства.
АутентификацияКаждое подписанное сообщение (запрос/ответ сопряжения, createChatItem чата, invite/update/kick канала) проверяется через Ed25519 по сохранённому public_key отправителя.
ЦелостностьПодписи Ed25519 покрывают тело запроса целиком; любое изменение нарушает подпись.
Устойчивость к повторуОкно метки времени ±5 мин (обеспечивается PeerChatParser и PairingSecurity). ChatMessageReceiver.seenSignatures выполняет дедупликацию внутри окна.
Устойчивость к MITMЭфемерный открытый ключ ECDH подписывается вместе с долгосрочным открытым ключом Ed25519. MITM не может подставить собственный ключ ECDH, не нарушив подпись.
Прямая секретность (ограниченная)Пары ключей ECDH эфемерны на каждую сессию сопряжения. Компрометация долгосрочного ключа Ed25519 позже не позволяет расшифровать прошлый трафик (общий ключ всё ещё нужен — но если одновременно стереть ECDH-приватные ключи и сохранённый DPeer.key, прошлые перехваты расшифровать невозможно).
Устойчивость к DoSonDatagram оборачивает каждое сообщение в try/catch, поэтому некорректный пакет не способен «убить» приёмник обнаружения. PeerCircuitBreaker пропускает ненадёжный транспорт на 30 с после 2 сбоев.
Конфиденциальность (направленное обнаружение)LANDiscoverManager.discoverSpecificDevice шифрует целевой clientId ключом пира — пассивные наблюдатели LAN не могут перечислить, кто с кем сопряжён.
Стабильность идентичностиclientId выводится из долгосрочного ключевого материала Ed25519 в платформенном keystore — стабилен при переустановках, самоаутентифицирующийся и не привязан к номеру телефона или email.

От чего сопряжение НЕ защищает

  • Физическая компрометация устройства. Если злоумышленник получает root на сопряжённом устройстве, он может прочитать общий ключ из базы данных и выдавать себя за этого пира. Аппаратное хранение для общего транспортного ключа не используется (только для ключа подписи Ed25519 через SignatureHelper).
  • Активные ретрансляционные атаки. Злоумышленник, способный одновременно ретранслировать BLE- и LAN-трафик между двумя устройствами, полагающими, что они сопрягаются друг с другом, теоретически может расположиться посередине — но подпись Ed25519 на открытом ключе ECDH означает, что он не сможет читать трафик, только ретранслировать его. Это тот же компромисс, что и при сопряжении Bluetooth без численного сравнения.
  • Блокировка на сетевом уровне. Межсетевой экран может блокировать UDP multicast, BLE может быть заглушен, Wi-Fi Aware может быть недоступен. Система корректно деградирует (BLE — гарантированный fallback для сопряжённых пиров), но не способна обойти активно враждебную сеть.

Сводка конечного автомата

Diagram 10
10

Дополнительная литература

  • Архитектура чата — для чего используется общий ключ: отправка/получение peer-чатов, fan-out каналов, presence, загрузка файлов.
  • apitest/groups/discovery.sh — исполняемый тест-план, проверяющий API обнаружения и сопряжения end-to-end.