1. Введение

Essenger Protocol обеспечивает сквозное шифрование сообщений в личных и групповых чатах на веб-, iOS- и macOS-клиентах. Он сочетает расширенный тройной обмен Диффи-Хеллмана (X3DH) с прямой секретностью (Double Ratchet) каждого сообщения, аутентифицированное шифрование AES-256-GCM и пост-квантовый гибридный слой — квантовый щит (ML-KEM-768, FIPS 203).

Essenger Protocol достигает четырёх целей безопасности:

  • Конфиденциальность — сообщения может прочитать только адресат.
  • Целостность — любое изменение шифротекста обнаруживается через аутентификационные теги AES-GCM и HMAC-SHA256.
  • Прямая секретность (Forward secrecy) — компрометация долгосрочных ключей не раскрывает прошлые сообщения. Эфемерные ключи X25519 генерируются для каждого сообщения (уровень аккаунта) или на каждый шаг DH-рэтчета (уровень сессии).
  • Пост-квантовая устойчивость — гибридный режим v3/v4 комбинирует X25519 с ML-KEM-768, чтобы сообщения оставались защищёнными даже при появлении квантового компьютера, способного взломать криптографию на эллиптических кривых.

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

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

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

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

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

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

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

ПримитивАлгоритмСсылкаПрименение
Согласование ключей X25519 RFC 7748 ECDH в X3DH, Double Ratchet, шифрование на уровне аккаунта, аутентификация отправителя
Цифровые подписи Ed25519 RFC 8032 Проверка подписанного пре-ключа
Аутентифицированное шифрование AES-256-GCM NIST SP 800-38D Шифрование сообщений, оборачивание ключей, шифрование хранилища
Аутентификация сообщений HMAC-SHA256 RFC 2104 KDF симметричного рэтчета, аутентификация отправителя, 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, Ed25519), @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. Протокол обмена ключами (X3DH)

Установка сессии использует протокол расширенного тройного обмена Диффи-Хеллмана (X3DH). Каждое устройство поддерживает следующий ключевой материал:

  • Identity Key (IK) — долгосрочная пара ключей X25519, генерируется при регистрации устройства. Публичный ключ загружается на сервер как identityKeyPublic.
  • Identity Signing Key — пара ключей Ed25519, используется для подписи Signed Pre-Key. Загружается как identitySigningKeyPublic.
  • Signed Pre-Key (SPK) — среднесрочная пара ключей X25519, подписанная Identity Signing Key (ed25519Sign(signingPriv, spk.publicKey)). Также служит начальным DH-рэтчет-ключом для ответчика.
  • One-Time Pre-Keys (OTPK) — пакет из 20 эфемерных пар ключей X25519, загружаемых при регистрации. Каждый OTPK используется не более одного раза.

Поток протокола

Алиса (инициатор) Сервер Боб (ответчик) -------- fetch bundle(Bob) --------> <------- {IKb, SPKb, sig, OTPKb} ------ // Проверяем подпись SPK ed25519Verify(IKb_signing, SPKb, sig) // TOFU: проверяем IKb по сохранённому значению // Генерируем эфемерную пару ключей EKa = X25519.generate() // Вычисляем значения DH DH1 = X25519(IKa_priv, SPKb) DH2 = X25519(EKa_priv, IKb) DH3 = X25519(EKa_priv, SPKb) DH4 = X25519(EKa_priv, OTPKb) // если OTPK доступен // Выводим общий секрет IKM = DH1 || DH2 || DH3 [|| DH4] SK = HKDF(IKM, salt=0x00*32, info="EssengerX3DH", len=32) -------- prekey message ----------> {IKa, EKa, usedOTPKID, // Боб вычисляет тот же SK header, ciphertext} DH1 = X25519(SPKb_priv, IKa) DH2 = X25519(IKb_priv, EKa) DH3 = X25519(SPKb_priv, EKa) DH4 = X25519(OTPKb_priv, EKa)

Спецификация KDF

function x3dhKdf(ikm):
    salt = 0x00 * 32           // 32 нулевых байта
    info = "EssengerX3DH"
    return HKDF-SHA256(ikm, salt, info, outputLen=32)
Проверка подписи обязательна

Инициатор всегда проверяет подпись Ed25519 на Signed Pre-Key перед продолжением. Если проверка не проходит, установка сессии прерывается. Это предотвращает подмену SPK злоумышленником.

5. Прямая секретность (Double Ratchet)

После завершения X3DH обе стороны инициализируют сессию Double Ratchet. Рэтчет обеспечивает прямую секретность и восстановление после взлома через непрерывную эволюцию ключей.

Состояние рэтчета

ПолеТипОписание
DHsПара ключей X25519Наша текущая пара DH-рэтчет-ключей
DHrПубличный ключ X25519Их текущий публичный DH-рэтчет-ключ
RK32 байтаКорневой ключ
CKs32 байтаЦепной ключ отправки
CKr32 байтаЦепной ключ приёма
Nsцелое числоСчётчик отправленных сообщений
Nrцелое числоСчётчик полученных сообщений
PNцелое числоДлина предыдущей цепи отправки
skippedKeysсловарь"DHr_b64:N" → ключ сообщения (макс. 500)
processedSetсловарь/множествоID уже расшифрованных сообщений (защита от replay)

Инициализация

Алиса (инициатор) выполняет немедленный шаг DH-рэтчета, используя SPK Боба как начальный удалённый рэтчет-ключ:

// Алиса создаёт сессию
DHs       = X25519.generate()
dhOut     = X25519(DHs.priv, SPKb)
(RK, CKs) = kdfRK(SK, dhOut)
CKr       = null
DHr       = SPKb

Боб (ответчик) использует свой SPK как начальную DH-рэтчет-пару без начальной цепи отправки:

// Боб создаёт сессию
DHs = SPKb_keypair
DHr = null
RK  = SK
CKs = null
CKr = null

Симметричный рэтчет ключей

function kdfCK(ck):
    mk    = HMAC-SHA256(ck, 0x01)   // ключ сообщения
    newCK = HMAC-SHA256(ck, 0x02)   // следующий цепной ключ
    return (newCK, mk)

Шаг DH-рэтчета

function dhRatchetStep(state, newDHr):
    state.PN  = state.Ns
    state.Ns  = 0
    state.Nr  = 0
    state.DHr = newDHr

    // Выводим цепь приёма из текущей DH-пары
    dhOut1         = X25519(state.DHs.priv, newDHr)
    (RK1, CKr)     = kdfRK(state.RK, dhOut1)
    state.RK       = RK1
    state.CKr      = CKr

    // Генерируем новую DH-пару и выводим цепь отправки
    state.DHs      = X25519.generate()
    dhOut2         = X25519(state.DHs.priv, newDHr)
    (RK2, CKs)     = kdfRK(RK1, dhOut2)
    state.RK       = RK2
    state.CKs      = CKs

KDF корневого ключа

function kdfRK(rk, dhOut):
    output = HKDF-SHA256(
        ikm  = dhOut,
        salt = rk,
        info = "EssengerRatchet",
        len  = 64
    )
    return (output[0:32], output[32:64])  // (новыйКорневойКлюч, цепнойКлюч)

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

function encrypt(state, plaintext):
    (newCK, mk) = kdfCK(state.CKs)
    state.CKs   = newCK
    header      = { dh: state.DHs.pub, pn: state.PN, n: state.Ns }
    state.Ns   += 1
    ad          = localIK || remoteIK || encodeHeader(header)
    ciphertext  = AES-256-GCM(mk, plaintext, ad)
    return (header, ciphertext)

Связанные данные (AEAD AD)

AD привязывает шифротекст к identity-ключам обеих сторон и заголовку сообщения:

AD = senderIdentityKey(32) || receiverIdentityKey(32) || encodeHeader(header)

// Бинарное кодирование заголовка (детерминистическое, без зависимости от JSON)
encodeHeader(header) = dh(32 байта) || pn(4 байта, big-endian) || n(4 байта, big-endian)
// Общая длина AD: 32 + 32 + 40 = 104 байта

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

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

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

Пропущенные ключи сообщений

Сообщения, пришедшие не по порядку, обрабатываются путём сохранения пропущенных ключей, индексированных по "DHr_base64:messageNumber". Константы:

  • MAX_SKIP = 50 — максимум сообщений, которые можно пропустить за один шаг цепи.
  • MAX_TOTAL_SKIPPED = 500 — максимум сохранённых пропущенных ключей на сессию.

Откат состояния при ошибке

Перед изменением состояния при расшифровке создаётся снимок рэтчет-состояния. Если расшифровка AES-GCM не удаётся (несовпадение аутентификационного тега), состояние откатывается к снимку. Это предотвращает повреждение состояния злонамеренными или повреждёнными сообщениями.

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

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

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

ВерсияБайт-префиксОписание
v10x01 (неявный)Статический X25519 ECDH. Устаревший, без прямой секретности. Сохранён только для совместимости со старыми сообщениями.
v20x02Эфемерный X25519 ECDH. Прямая секретность для каждого сообщения.
v30x03Гибрид ML-KEM-768 + эфемерный X25519. Фиксированный размер PQ-шифротекста (1088 байт).
v40x04Гибрид ML-KEM-768 + эфемерный X25519, с 2-байтным префиксом длины PQ-шифротекста для расширяемости.

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-формат v4 (гибридный, текущий)

0x041 Б
ephPub32 Б
pqCTLen2 Б
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 является безопасным комбинатором в стандартной модели.

Wire-формат v3 (устаревший гибрид)

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

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

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

При расшифровке проверяется первый байт шифротекста для определения версии: 0x04 для v4, 0x03 для v3, 0x02 для v2. Если ни один не совпал — сообщение обрабатывается как v1 (устаревший статический ECDH). Новые сообщения всегда отправляются как v2 (когда у получателя нет PQ-ключа) или v4 (когда 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

Sealed sender скрывает личность отправителя внутри E2E-зашифрованной нагрузки, чтобы серверу не нужно было знать, кто отправил сообщение (личность отправителя узнаёт только получатель).

Формат

Когда sealed sender активен, открытый текст оборачивается в JSON-конверт перед шифрованием:

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

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

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

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, возвращаем как есть
Ограничения по метаданным

Sealed sender скрывает личность отправителя от сервера на уровне приложения. Однако сервер по-прежнему видит получателя, время отправки и размер сообщения. Метаданные транспортного уровня (IP-адреса, идентификаторы TLS-сессий) этим механизмом не скрываются.

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

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

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

  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.

Прямая секретность в группах

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

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

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

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

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

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

Trust On First Use (TOFU)

Identity-ключи верифицируются через TOFU. При первом контакте с устройством пользователя identity-ключ сохраняется локально под ключом tofu_{username}:{deviceID}. При последующих сессиях сохранённый ключ сравнивается с полученным. Если они отличаются, сессия отклоняется с кодом ошибки IDENTITY_KEY_CHANGED.

// TOFU-проверка (по устройству)
storedIK = load("tofu_{username}:{deviceID}")
if storedIK exists and storedIK !== remoteIK:
    throw "Identity key changed — возможна MITM-атака"
else if storedIK absent:
    save("tofu_{username}:{deviceID}", remoteIK)

Числа безопасности (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 для предотвращения ретроактивной модификации.

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

  • Signed Pre-Keys периодически перегенерируются. Назначается новый случайный keyID, генерируется новая пара X25519, и публичный ключ подписывается identity signing key.
  • One-Time Pre-Keys генерируются пакетами по 20. Когда серверный запас исчерпан, клиент загружает свежий пакет.
  • Версия протокола сессии: клиент отслеживает константу PROTOCOL_VERSION (текущее значение 3). При обновлении все устаревшие сессии очищаются для принудительного переустановления с новым протоколом.

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

Приватные ключи хранятся локально на устройстве (режим 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. Вопросы безопасности

Защита от повторного воспроизведения (replay)

Double Ratchet поддерживает множество processedSet (веб) / processedMessages (iOS), в котором записывается DHr_base64:messageNumber каждого успешно расшифрованного сообщения. Если ID сообщения уже есть в множестве, расшифровка отклоняется с ошибкой replayDetected.

  • Веб: множество очищается при превышении 10 000 записей, удаляя записи старше 30 дней.
  • iOS: ограниченное FIFO-вытеснение удаляет 1 000 старейших записей при превышении 10 000.

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

Вся проверка 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. Это минимизирует окно, в течение которого чувствительный материал существует в памяти.

Rate limiting PreKey-сообщений

Ответчик (Боб) ограничивает новые PreKey-сессии до 5 на пользователя за 60 секунд. Это предотвращает атаки исчерпания ресурсов, при которых злоумышленник заваливает пользователя PreKey-сообщениями для расходования одноразовых пре-ключей.

Блокировка сессий

Одновременные операции шифрования/расшифровки на одной рэтчет-сессии сериализуются через per-session мьютексы (_acquireSessionLock). Это предотвращает гонки состояний, которые могут повредить состояние рэтчета в асинхронных средах.

Миграция версии протокола

Константа версии протокола (PROTOCOL_VERSION = 3) отслеживается в localStorage. При увеличении версии все существующие сессии очищаются для принудительного переустановления. Это гарантирует, что изменения формата AD не вызовут тихих ошибок расшифровки.

Откат состояния

И веб-, и iOS-реализации создают снимок рэтчет-состояния перед мутациями при расшифровке. Если расшифровка AES-GCM не удаётся (несовпадение тега), состояние восстанавливается из снимка. Это предотвращает необратимое повреждение сессии одним повреждённым сообщением.

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

  • Нет отрицаемости: HMAC аутентификации отправителя использует статический ECDH-ключ, создавая криптографическое доказательство того, что конкретный отправитель создал сообщение.
  • Метаданные группы: словарь mk в сообщениях group v2 раскрывает состав группы любому наблюдателю, который видит шифротекст (хотя содержимое сообщения зашифровано).
  • Модель памяти JavaScript: .fill(0) на Uint8Array — это best-effort в рантаймах со сборщиком мусора. JavaScript-движок может сохранять копии чувствительных данных в памяти.
  • TOFU без PKI: начальный обмен ключами доверяется при первом использовании. Активная MITM-атака в момент первого контакта может подменить ключи. Числа безопасности позволяют ручную верификацию, но не принуждают к ней.

13. Ссылки