1. Введение

Essenger Protocol обеспечивает сквозное шифрование сообщений в личных и групповых чатах на Web/PWA и iOS beta; macOS-клиент не входит в объём v1.0. Он сочетает гибридное согласование ключей (эфемерный X25519 + ML-KEM-768, FIPS 203) через HKDF-SHA256, per-message эфемерные ключи на стороне отправителя, аутентифицированное шифрование AES-256-GCM и аутентификацию отправителя HMAC-SHA256. Это не Double Ratchet и не X3DH — подробности в разделе ограничений.

Essenger Protocol достигает следующих целей безопасности:

  • Конфиденциальность — сообщения может прочитать только адресат; сервер хранит только шифротекст.
  • Целостность — любое изменение шифротекста обнаруживается через аутентификационные теги AES-GCM и HMAC-SHA256.
  • Per-message эфемерный ключ отправителя — каждое сообщение выводит ключ из свежей эфемерной пары X25519 на стороне отправителя (она удаляется после отправки), поэтому компрометация долгосрочного ключа отправителя не раскрывает задним числом уже отправленные им сообщения. Это не полная прямая секретность: получатель использует статический долгосрочный ключ. Полная forward secrecy и post-compromise security (1:1 и группы) не заявляются — см. раздел ограничений.
  • Пост-квантовая устойчивость — гибридный режим v3 комбинирует X25519 с ML-KEM-768, чтобы сообщения оставались защищёнными от атаки «собери сейчас, расшифруй потом» при появлении квантового компьютера, способного взломать криптографию на эллиптических кривых.

Шифрование обязательно. Режима передачи открытого текста не существует.

2. Модель угроз

От чего мы защищаем

  • Пассивный перехватчик — злоумышленник, наблюдающий за всем сетевым трафиком между клиентами и сервером, не может прочитать содержимое сообщений.
  • Скомпрометированный сервер — сервер хранит только шифротексты, публичные ключи и зашифрованные блобы приватных ключей. Полная компрометация сервера не раскрывает открытый текст сообщений или приватные ключи.
  • Будущий квантовый компьютер — гибридные сообщения v3/v4 комбинируют классический X25519 с ML-KEM-768. Злоумышленник, который записывает шифротекст сегодня и позже получит криптографически значимый квантовый компьютер, не сможет расшифровать гибридно-зашифрованные сообщения.
  • Имперсонация при компрометации ключа — аутентификация отправителя через HMAC-SHA256 по статическому общему секрету ECDH предотвращает подмену личности даже при компрометации сервера.

От чего мы не защищаем

  • Компрометация устройства — если злоумышленник получает полный контроль над устройством (root-доступ, вредоносное ПО), он может читать расшифрованные сообщения из памяти или локального хранилища.
  • Метаданные — сервер видит аккаунт отправителя и получателя на текущем active path, время доставки, размер сообщения и сетевые метаданные. Server-blind Sealed Sender находится в redesign и не заявляется как shipped-функция.
  • Обход аутентификации пользователя — протокол не защищает от социальной инженерии или перехвата аккаунта на уровне приложения. TOTP 2FA и TOFU смягчают, но не устраняют этот риск.

3. Криптографические примитивы

ПримитивАлгоритмСсылкаПрименение
Согласование ключей X25519 RFC 7748 Эфемерный ECDH отправителя, шифрование на уровне аккаунта, аутентификация отправителя
Аутентифицированное шифрование AES-256-GCM NIST SP 800-38D Шифрование сообщений, оборачивание ключей, шифрование хранилища
Аутентификация сообщений HMAC-SHA256 RFC 2104 Аутентификация отправителя, MAC хранилища
Вывод ключей HKDF-SHA256 RFC 5869 Вывод ключа сообщения, комбинирование гибридного секрета, ключ восстановления
Пост-квантовый KEM ML-KEM-768 FIPS 203 Гибридная инкапсуляция ключей в сообщениях v3/v4
KDF для пароля (основной) Argon2id RFC 9106 Шифрование приватного ключа (64 МБ, t=3, p=1)
KDF для пароля (запасной) PBKDF2-SHA256 RFC 8018 Устаревшее шифрование приватного ключа (600K итераций)

Библиотеки реализации

  • Веб-клиент: @noble/curves (X25519), @noble/hashes (HKDF, HMAC, SHA-256), @noble/post-quantum (ML-KEM-768), Web Crypto API (AES-GCM), argon2-browser (Argon2id)
  • iOS/macOS-клиент: Apple CryptoKit (Curve25519, AES.GCM, HKDF<SHA256>, HMAC<SHA256>)

4. Согласование ключей (эфемерный X25519)

Essenger не использует X3DH или прекей-бандлы. Каждое устройство при регистрации генерирует одну долгосрочную пару ключей X25519 и (для пост-квантового пути) пару ключей ML-KEM-768. Публичные ключи загружаются на сервер; приватные ключи никогда не покидают устройство.

  • Долгосрочный ключ X25519 — пара ключей, генерируется на устройстве при регистрации. Публичный ключ публикуется на сервере; на него адресуются входящие сообщения.
  • Ключ ML-KEM-768 — пост-квантовая пара ключей для гибридного пути v3 (только web по умолчанию). Публичный ключ публикуется на сервере.
  • TOFU по UUID — публичный ключ собеседника закрепляется (trust on first use) по неизменному User UUID при первом контакте.

Поток на каждое сообщение

Согласование ключей выполняется заново для каждого сообщения. Отправитель генерирует свежую эфемерную пару X25519, выполняет ECDH с опубликованным статическим ключом получателя, выводит ключ сообщения через HKDF-SHA256 и уничтожает эфемерный приватный ключ:

Отправитель Сервер Получатель ------- fetch publicKey(B) -------> <------ recipientX25519Pub ---------- // TOFU: проверяем ключ получателя по сохранённому значению (по UUID) // Свежая эфемерная пара на это сообщение ephPriv = random(32) ephPub = X25519.publicKey(ephPriv) shared = X25519(ephPriv, recipientX25519Pub) msgKey = HKDF-SHA256(shared, salt=ephPub, info="essenger-e2e-v2", len=32) // ephPriv обнуляется сразу после использования --------- {ephPub, ct, sa} -------> shared = X25519(myPriv, ephPub) msgKey = HKDF-SHA256(shared, salt=ephPub, ...)

Спецификация KDF (DM v2)

function messageKeyV2(ephPriv, recipientPub):
    shared = X25519(ephPriv, recipientPub)
    salt   = ephPub
    info   = "essenger-e2e-v2"
    return HKDF-SHA256(shared, salt, info, outputLen=32)
Без прекеев и без сессий

Здесь нет signed pre-keys, one-time pre-keys, проверки подписей Ed25519 или установки длительной сессии. Каждое сообщение самодостаточно: оно несёт собственный эфемерный публичный ключ отправителя, и получатель выводит ключ сообщения напрямую из своего статического приватного ключа. Гибридный пост-квантовый вариант (v3) добавляет к этому ML-KEM-768 — см. раздел 6.

5. Per-message ключи отправителя

В обычных чатах Essenger не реализует Double Ratchet. Нет корневого ключа, цепных ключей, рэтчет-сессий или эволюции состояния между сообщениями. Каждое сообщение полностью независимо: оно использует свежую эфемерную пару X25519 на стороне отправителя (раздел 4) и шифруется собственным одноразовым ключом сообщения. Полноценный Double Ratchet — с прямой секретностью и post-compromise security — доступен в секретных чатах (раздел 13).

Шифрование сообщения

function encrypt(plaintext, recipientPub, senderStaticPriv):
    // Свежая эфемерная пара на это сообщение
    ephPriv    = random(32)
    ephPub     = X25519.publicKey(ephPriv)
    shared     = X25519(ephPriv, recipientPub)
    msgKey     = HKDF-SHA256(shared, salt=ephPub, info="essenger-e2e-v2", len=32)

    blob       = 0x02 || ephPub(32) || nonce(12) || AES-256-GCM(msgKey, plaintext)
    senderAuth = HMAC-SHA256(blob, key=staticECDH(senderStaticPriv, recipientPub))

    // ephPriv обнуляется сразу после использования
    return { ct: blob, sa: senderAuth }

Комбинированный формат AES-GCM

Весь шифротекст AES-256-GCM использует комбинированный формат, совместимый с Apple CryptoKit:

nonce12 байт
ciphertextпеременный
tag16 байт

Что это даёт — и чего не даёт

Обычные чаты — не Double Ratchet: нет полной forward secrecy и post-compromise security

Сказанное ниже относится к обычным чатам. Для полной прямой секретности откройте секретный чат — он работает на Double Ratchet (раздел 13). В обычном чате эфемерным является только ключ отправителя. Сообщение адресуется на статический долгосрочный X25519-ключ получателя; ротации или рэтчета ключей нет. Это означает:

  • Компрометация долгосрочного ключа отправителя не раскрывает задним числом уже отправленные им сообщения (эфемерные ключи отправителя уже уничтожены).
  • Компрометация долгосрочного приватного ключа получателя раскрывает как прошлые, так и будущие адресованные ему сообщения.
  • Post-compromise security (восстановление после взлома) не обеспечивается. Signal-подобный рэтчет вынесен в post-1.0 release.

6. Квантовый щит (ML-KEM-768, v3/v4)

Шифрование на уровне аккаунта поддерживает гибридный пост-квантовый режим, комбинирующий X25519 ECDH с ML-KEM-768 (механизм инкапсуляции ключей на основе модульных решёток, FIPS 203). Это обеспечивает защиту от атаки «собери сейчас, расшифруй потом»: даже если весь шифротекст записан сегодня, будущий квантовый компьютер не сможет вычислить ключи сообщений.

Версии формата сообщений

ВерсияБайт-префиксОписание
v10x01 (неявный)Статический X25519 ECDH. Устаревший. Сохранён только для совместимости со старыми сообщениями.
v20x02Эфемерный X25519 ECDH. Per-message эфемерный ключ отправителя. Текущий fallback, когда у получателя нет PQ-ключа.
v30x03Гибрид ML-KEM-768 + эфемерный X25519. Активный пост-квантовый путь (web→web по умолчанию). Фиксированный размер PQ-шифротекста (1088 байт).
v40x04Зарезервированная расширяемая гибридная разметка с 2-байтным префиксом длины PQ-шифротекста. Не активирована (нет call-site) и не отправляется.

Wire-формат v2 (текущий по умолчанию)

0x021 байт
ephPub32 байта
IV12 байт
ciphertext + tagпеременный
// Вывод ключа v2
ephPriv    = random(32)
ephPub     = X25519.publicKey(ephPriv)
shared     = X25519(ephPriv, recipientPub)
messageKey = HKDF-SHA256(shared, salt=ephPub, info="essenger-e2e-v2", len=32)
// ephPriv обнуляется сразу после использования

Wire-формат v3 (активный гибридный путь)

0x031 Б
ephPub32 Б
ML-KEM CT1088 Б
IV12 Б
ct + tagперем.

Комбинирование гибридных ключей

// Отправитель (шифрование)
x25519Shared              = X25519(ephPriv, recipientX25519Pub)
(pqCipherText, pqShared)  = ML-KEM-768.Encapsulate(recipientPQPub)
combinedSecret            = x25519Shared || pqShared
messageKey                = HKDF-SHA256(combinedSecret, salt=ephPub, info="essenger-hybrid-v1", len=32)

// Получатель (расшифровка)
x25519Shared  = X25519(myPriv, ephPub)
pqShared      = ML-KEM-768.Decapsulate(pqCipherText, myPQPriv)
combinedSecret = x25519Shared || pqShared
messageKey    = HKDF-SHA256(combinedSecret, salt=ephPub, info="essenger-hybrid-v1", len=32)
Гарантия безопасности

Гибридная комбинация безопасна, пока хотя бы один из двух алгоритмов не взломан. Если X25519 будет взломан квантовым компьютером, но ML-KEM-768 останется надёжным — комбинированный ключ по-прежнему безопасен, и наоборот. Конкатенация с последующим HKDF является безопасным комбинатором в стандартной модели.

Зарезервированная разметка v4

0x041 Б
ephPub32 Б
pqCTLen2 Б
ML-KEM CT1088 Б
IV12 Б
ct + tagперем.

v4 идентичен v3, но добавляет 2-байтный префикс длины pqCTLen для будущей расширяемости на бо́льшие параметры KEM. Эта разметка пока не активирована (нет call-site) и не отправляется.

Обратная совместимость

При расшифровке проверяется первый байт шифротекста для определения версии: 0x03 для гибридного v3, 0x02 для эфемерного X25519 v2. Если ни один не совпал — сообщение обрабатывается как v1 (устаревший статический ECDH). Новые сообщения отправляются как v3 (когда у получателя есть PQ-ключ — по умолчанию web→web) или как v2 (когда PQ-ключа нет).

7. Аутентификация отправителя

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

Вычисление

function computeSenderAuth(ciphertextBytes, senderPriv, recipientPub):
    staticShared = X25519(senderPriv, recipientPub)
    authKey      = HKDF-SHA256(staticShared, salt=empty, info="essenger-sender-auth", len=32)
    mac          = HMAC-SHA256(authKey, ciphertextBytes)
    // Обнуляем staticShared и authKey немедленно
    return mac  // 32 байта

Проверка

function verifySenderAuth(ciphertextBytes, expectedMac, senderPub, myPriv):
    staticShared = X25519(myPriv, senderPub)
    authKey      = HKDF-SHA256(staticShared, salt=empty, info="essenger-sender-auth", len=32)
    computed     = HMAC-SHA256(authKey, ciphertextBytes)
    return constantTimeEqual(computed, expectedMac)

Формат конверта

Аутентифицированный шифротекст передаётся как JSON-конверт:

{
  "ct": "<base64 версионированный шифротекст>",
  "sa": "<base64 HMAC-SHA256, 32 байта>"
}

Кросс-платформенная совместимость

JSON-обёртка гарантирует, что и веб-клиент (@noble/curves), и iOS-клиент (CryptoKit) могут разбирать один и тот же формат сообщений. Базовый общий секрет X25519 детерминистичен во всех реализациях.

Зависимость от TOFU

Аутентификация отправителя проверяется только при наличии публичного ключа отправителя в локальном TOFU-кэше. Для неизвестных отправителей проверка пропускается (мягкая деградация). После того как TOFU запомнит ключ, все последующие сообщения аутентифицируются.

8. Sealed Sender

Текущая shipping-реализация скрывает личность отправителя внутри E2E-зашифрованной нагрузки для клиента-получателя, но доставка на активном пути идёт через authenticated WebSocket. Поэтому сервер всё ещё знает аккаунт отправителя для маршрутизации. Полностью server-blind Sealed Sender — отдельный redesign в roadmap, а не текущий shipped claim.

Формат

На текущем клиентском уровне открытый текст оборачивается в JSON-конверт перед шифрованием:

{
  "_ss":  true,              // флаг sealed sender
  "sid":  "<ID отправителя>",
  "sun":  "<имя отправителя>",
  "msg":  "<текст сообщения>"
}

Этот JSON шифруется публичным ключом получателя стандартным путём v2/v3/v4. Получатель расшифровывает, обнаруживает _ss: true и извлекает отображаемую личность отправителя из нагрузки. Это не делает текущий транспорт server-blind.

Логика распаковки

function unwrapSealedSender(plaintext):
    if plaintext starts with '{':
        parsed = JSON.parse(plaintext)
        if parsed._ss === true && typeof parsed.msg === 'string':
            return { text: parsed.msg, senderId: parsed.sid, senderUsername: parsed.sun }
    return plaintext  // не sealed sender, возвращаем как есть
Ограничения по метаданным

На текущем активном пути сервер видит отправителя, получателя, время отправки, размер сообщения и сетевые метаданные. Server-blind доставка требует отдельного access-key/sealed-inbox дизайна и не заявляется как shipped-функция.

9. Групповое E2E v2

Групповое шифрование использует свежий случайный ключ сообщения и эфемерную пару ECDH отправителя для каждого сообщения, оборачивая ключ сообщения отдельно для каждого участника. Это обеспечивает конфиденциальность и независимость сообщений (компрометация одного ключа сообщения не раскрывает остальные). Однако ключ сообщения оборачивается под долгосрочный публичный ключ каждого участника. Как и в личных чатах, здесь нет Double Ratchet: групповые сообщения не обеспечивают прямую секретность — компрометация долгосрочного приватного ключа участника раскрывает как прошлые, так и будущие адресованные ему сообщения. Ротация групповых ключей и forward secrecy для групп — в roadmap (не реализованы). Группа использует только X25519 без ML-KEM (без PQ-слоя).

Поток шифрования

  1. Генерируем случайный 32-байтный messageKey.
  2. Генерируем эфемерную пару ключей X25519 (ephPriv, ephPub).
  3. Шифруем открытый текст с messageKey через AES-256-GCM (случайный 12-байтный IV).
  4. Для каждого участника группы:
    • Вычисляем: shared = X25519(ephPriv, memberPub)
    • Выводим ключ оборачивания: wrappingKey = HKDF-SHA256(shared, salt=ephPub, info="essenger-group-e2e-v2", len=32)
    • Оборачиваем: wrappedMK = AES-256-GCM(wrappingKey, messageKey, randomIV)
    • Сохраняем как: base64(mkIV(12) || wrappedMK+tag)
    • Обнуляем ключ оборачивания немедленно.
  5. Обнуляем ephPriv (эфемерный приватный ключ никогда не покидает память).

Wire-формат

{
  "v":   2,
  "eph": "<base64 эфемерный публичный ключ, 32 байта>",
  "iv":  "<base64 AES-GCM nonce, 12 байт>",
  "ct":  "<base64 шифротекст + тег>",
  "mk":  {
    "<username1>": "<base64 mkIV(12) || wrappedMK+tag>",
    "<username2>": "<base64 mkIV(12) || wrappedMK+tag>",
    ...
  }
}

Поток расшифровки

  1. Разбираем JSON-нагрузку, проверяем v === 2.
  2. Ищем свой username в словаре mk. Если отсутствует — сообщение не предназначено для этого пользователя.
  3. Выводим ключ оборачивания: shared = X25519(myPriv, ephPub), затем HKDF как выше.
  4. Разворачиваем: разделяем mkIV(12) || wrappedMK+tag, расшифровываем ключом оборачивания.
  5. Расшифровываем шифротекст развёрнутым messageKey.

Эфемерный ключ отправителя в группах

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

Добавление и удаление участников

  • Добавление: новые участники просто включаются в словарь mk последующих сообщений. Они не могут расшифровать сообщения, отправленные до их добавления (нет доступа к предыдущим эфемерным ключам).
  • Удаление: удалённые участники исключаются из словаря mk. Поскольку каждое сообщение использует свежий эфемерный ключ, удалённый участник не может вычислить будущие ключи оборачивания.

Устаревший формат v1

Протокол v1 для групп использовал статические ключи отправителя без оборачивания для каждого участника. Сохранён только для обратной совместимости (decryptGroupMessageV1 делегирует в accountE2E.decrypt). Авто-определение сначала проверяет groupCiphertextV2, откатываясь к ciphertext для v1.

10. Управление ключами

Trust On First Use (TOFU)

Долгосрочные публичные ключи верифицируются через TOFU по неизменному User UUID. При первом контакте публичный ключ собеседника сохраняется локально под ключом tofu_{uuid}. При последующих контактах сохранённый ключ сравнивается с полученным. Если они отличаются, контакт отклоняется с кодом ошибки IDENTITY_KEY_CHANGED.

// TOFU-проверка (по неизменному UUID)
storedKey = load("tofu_{uuid}")
if storedKey exists and storedKey !== remotePub:
    throw "Публичный ключ изменился — возможна MITM-атака"
else if storedKey absent:
    save("tofu_{uuid}", remotePub)

Числа безопасности (Safety Numbers)

Пользователи могут вручную верифицировать друг друга, сравнивая числа безопасности. Число безопасности вычисляется путём лексикографической сортировки публичных ключей обеих сторон, конкатенации, хеширования SHA-256 и форматирования в 12 групп по 5 цифр:

function generateSafetyNumber(myPubB64, theirPubB64):
    sorted   = [myPubB64, theirPubB64].sort()
    combined = sorted[0] + sorted[1]
    hash     = SHA-256(combined)
    digits   = ""
    for i in 0..29:
        digits += String(hash[i] % 100).padStart(2, '0')
    // Форматируем как 12 групп по 5 цифр
    return groups of 5, space-separated  // напр. "04821 73920 ..."

Аудит-лог прозрачности ключей

Сервер ведёт append-only таблицу key_audit_log, фиксирующую все события, связанные с ключами. Каждая запись содержит:

ПолеТипОписание
user_idUUIDПользователь, чей ключ изменился
actionstringТип события (register, rotate, revoke)
public_key_hashstringSHA-256 хеш нового публичного ключа
device_infostringИмя/тип устройства, инициировавшего изменение
ip_addressstringIP-адрес источника запроса
created_atdatetimeВременная метка

Аудит-лог защищён от записи политиками PostgreSQL для предотвращения ретроактивной модификации.

Ротация ключей

  • Долгосрочный ключ аккаунта может быть перегенерирован (новая пара X25519 и при необходимости ML-KEM-768). Событие фиксируется в key_audit_log с действием rotate/revoke.
  • TOFU-перезакрепление: при смене опубликованного ключа собеседника закреплённое значение (по UUID) изменяется, и клиент сигнализирует об этом пользователю. Прекеев и периодической ротации сессионных ключей нет — каждое сообщение использует свежий эфемерный ключ отправителя.

Хранение приватных ключей

Приватные ключи хранятся локально на устройстве (режим device-local). В состоянии покоя приватные ключи в localStorage шифруются ключом шифрования хранилища (SEK), который существует только в sessionStorage (очищается при закрытии вкладки):

Формат: "sek1:" + base64(IV(12) || AES-GCM(SEK, privateKey))
SEK = случайные 32 байта, хранится только в sessionStorage

Для ключей, зашифрованных на сервере, приватный ключ шифруется паролем пользователя:

  • Argon2id (основной): 0xA2 || salt(16) || IV(12) || AES-GCM(derived, privKey). Параметры: память 64 МБ, time cost 3, параллелизм 1, длина хеша 32 байта.
  • PBKDF2 (запасной): salt(16) || IV(12) || AES-GCM(derived, privKey). 600K итераций SHA-256. Используется, когда Argon2 WASM недоступен. Устаревшие ключи со 100K итераций автоматически мигрируются.

11. Восстановление

Восстановление аккаунта использует мнемоническую фразу BIP39 (12 слов, 128 бит энтропии) как человекочитаемую резервную копию криптографической идентичности.

Генерация мнемоники

phrase = BIP39.generateMnemonic(wordlist=english, entropy=128)
// Генерирует 12 английских слов, напр. "abandon ability able about above absent..."

Вывод ключа восстановления

seed        = BIP39.mnemonicToSeed(phrase)  // 64 байта
recoveryKey = HKDF-SHA256(
    ikm  = seed,
    salt = "essenger-recovery-v1",
    info = "essenger-recovery-v1",
    len  = 32
)
// recoveryKey: Uint8Array(32)

Формат зашифрованного бэкапа (RP1)

Приватный ключ шифруется ключом восстановления и загружается как резервная копия:

"RP1"3 байта
salt16 байт
IV12 байт
AES-GCM(privKey) + tag48 байт
// Шифрование
salt   = random(16)
aesKey = HKDF-SHA256(recoveryKey, salt, info="essenger-e2e-privkey-v1", len=32)
iv     = random(12)
blob   = "RP1" || salt || iv || AES-GCM(aesKey, privateKey, iv)
output = base64(blob)

Поток восстановления

  1. Пользователь вводит мнемоническую фразу из 12 слов.
  2. Фраза валидируется по словарю BIP39 (с нормализацией: нижний регистр, удаление нумерации, схлопывание пробелов).
  3. Ключ восстановления выводится через HKDF.
  4. Блоб RP1 расшифровывается. Полученный приватный ключ верифицируется по ожидаемому публичному ключу.
  5. При успехе приватный ключ загружается в память и session storage.

Хранение мнемоники

Мнемоническая фраза опционально кэшируется в sessionStorage на время текущей сессии, зашифрованная per-session ключом AES-256-GCM, который существует только в памяти. Мнемоника никогда не записывается в localStorage и не отправляется на сервер.

12. Вопросы безопасности

Сравнение за постоянное время

Вся проверка MAC использует сравнение за постоянное время для предотвращения атак по таймингу:

function equal(a, b):
    if a.length !== b.length: return false
    diff = 0
    for i in 0..a.length:
        diff |= a[i] ^ b[i]
    return diff === 0

Обнуление ключевого материала

Эфемерные приватные ключи, общие секреты и выведенный ключевой материал обнуляются (.fill(0)) немедленно после использования, как правило в блоке finally. Это минимизирует окно, в течение которого чувствительный материал существует в памяти.

Известные ограничения

  • В обычных чатах нет Double Ratchet: там нет полной forward secrecy и post-compromise security. Эфемерным является только ключ отправителя; получатель использует статический долгосрочный ключ, поэтому компрометация его приватного ключа раскрывает прошлые и будущие адресованные ему сообщения. Для полной прямой секретности есть секретные чаты на Double Ratchet (раздел 13); групповые чаты рэтчет пока не используют.
  • Нет отрицаемости: HMAC аутентификации отправителя использует статический ECDH-ключ, создавая криптографическое доказательство того, что конкретный отправитель создал сообщение.
  • Метаданные группы: словарь mk в сообщениях group v2 раскрывает состав группы любому наблюдателю, который видит шифротекст (хотя содержимое сообщения зашифровано).
  • Модель памяти JavaScript: .fill(0) на Uint8Array — это best-effort в рантаймах со сборщиком мусора. JavaScript-движок может сохранять копии чувствительных данных в памяти.
  • TOFU без PKI: начальный обмен ключами доверяется при первом использовании (по неизменному User UUID). Активная MITM-атака в момент первого контакта может подменить ключи. Числа безопасности позволяют ручную верификацию, но не принуждают к ней.
  • Без внешнего аудита: протокол открыт, но не проверен независимой третьей стороной. Статус — experimental.

13. Секретные чаты (Double Ratchet)

Секретный чат — это отдельный, включаемый вручную диалог рядом с обычным. В отличие от обычных чатов (раздел 5), он работает на полном Double Ratchet поверх X3DH-хендшейка и даёт прямую секретность и post-compromise security. На этом же транспорте работают одноразовые (view-once) сообщения. Реализации на iOS и в вебе — побайтовое зеркало друг друга и покрыты замороженными тест-векторами (dr_handshake_v6). Секретные чаты — 1:1; групповой рэтчет не заявляется.

Установление сессии — X3DH

У каждого устройства есть отдельная DR-личность (хранится в Keychain под собственным сервисом, независимо от ключа обычных чатов; общий с аккаунтом только числовой deviceID):

  • Identity key — X25519 (Curve25519) ключ согласования, привязывающий транскрипт.
  • Signing key — Ed25519; им подписывается signed pre-key (SPK).
  • Signed pre-key + пул one-time pre-keys (OTPK) — публикуются на сервер; OTPK расходуется при первом входящем хендшейке.

Инициатор выполняет X3DH против опубликованного набора получателя, проверяя Ed25519-подпись SPK (неверная подпись → invalidSignedPreKeySignature, признак MITM). Из набора Диффи-Хеллманов X3DH выводится корневой ключ, которым засевается рэтчет:

// Инициатор → получатель, первый контакт
DH1 = X25519(IK_a, SPK_b)
DH2 = X25519(EK_a, IK_b)
DH3 = X25519(EK_a, SPK_b)
DH4 = X25519(EK_a, OTPK_b)   // если OTPK доступен
rootKey = HKDF-SHA256(DH1 ‖ DH2 ‖ DH3 ‖ DH4)

Рэтчет

Дальше состояние ведёт классический Double Ratchet: DH-рэтчет (новая пара X25519 при смене направления) поверх симметричного рэтчета корневого и цепных ключей. Каждое сообщение получает собственный message key, выведенный из цепного; отправленные ключи немедленно удаляются. Отсюда обе гарантии: forward secrecy (взлом текущего состояния не раскрывает прошлые сообщения) и post-compromise security (после одного «чистого» обмена стороны восстанавливают секретность). Каждая сессия — на пару username:deviceID и сериализована, чтобы параллельная запись не повредила состояние рэтчета (Ns/CKs/DHs/PN).

Wire-формат (frame 0x06)

Живые сообщения секретного чата ходят самоописываемым фреймом 0x06:

version0x06
flags1 байт · bit0 = INITIAL (X3DH)
bodyLenuint16-BE
bodyJSON: конверт §8 + senderDeviceID

senderDeviceID — только маршрутизация (какая сессия): полезную нагрузку аутентифицирует сам рэтчет (AAD), поэтому подмена id ничего не даёт. Флаг INITIAL помечает первый (prekey) фрейм, несущий эфемерный публичный ключ инициатора.

Доверие и защита

  • Per-device TOFU: удалённый identity key устройства фиксируется при первом контакте; смена → identityKeyChanged (признак MITM), сессия не устанавливается молча.
  • Fail-closed: если DR-сессию к устройству получателя установить нельзя (нет опубликованного набора), секретное/одноразовое сообщение не отправляется открытым текстом — оно не отправляется вовсе.
  • Anti-DoS: не более 5 prekey-сообщений в минуту на отправителя.
Что остаётся честно сказать
  • Секретные чаты — 1:1; групповой Double Ratchet не заявляется.
  • Начальное доверие — TOFU без PKI: активная MITM ровно в момент первого контакта возможна; числа безопасности позволяют ручную сверку.
  • Протокол открыт и покрыт тест-векторами, но независимый внешний аудит ещё не проведён. Статус — experimental.

14. Ссылки