Веб

Уязвимости E2EE сквозного шифрования в веб-приложениях ECDH AES-GCM

Потенциальные уязвимости безопасности при использовании сквозного шифрования E2EE в веб-приложениях с ECDH для обмена ключами и AES-GCM. Анализ кода, угрозы от недоверенного сервера, улучшения для повышения безопасности без компрометации устройства.

Каковы потенциальные уязвимости в безопасности при использовании сквозного шифрования (E2EE) в веб-приложении, где сервер контролируется разработчиком, но считается недоверенным лицом? В частности, какие риски существуют в предложенной реализации, использующей ECDH для обмена ключами и AES-GCM для шифрования, с учетом следующего JS-кода на стороне клиента?

javascript
class msgCrypt {

 constructor(publicKeyJwk, privateKeyJwk) {

 this.publicKeyJwk = publicKeyJwk;
 this.privateKeyJwk = privateKeyJwk;

 }

 static async generateKeyPair() {

 const keyPair = await window.crypto.subtle.generateKey(
 {
 name: "ECDH",
 namedCurve: "P-256",
 },
 true,
 ["deriveKey", "deriveBits"]
 );

 const publicKeyJwk = await window.crypto.subtle.exportKey(
 "jwk",
 keyPair.publicKey
 );

 const privateKeyJwk = await window.crypto.subtle.exportKey(
 "jwk",
 keyPair.privateKey
 );

 return {publicKeyJwk, privateKeyJwk};

 }

 async deriveKey() {

 const publicKeyJwk = this.publicKeyJwk;
 const privateKeyJwk = this.privateKeyJwk;

 const publicKey = await window.crypto.subtle.importKey(
 "jwk",
 publicKeyJwk,
 {
 name: "ECDH",
 namedCurve: "P-256",
 },
 true,
 []
 );

 const privateKey = await window.crypto.subtle.importKey(
 "jwk",
 privateKeyJwk,
 {
 name: "ECDH",
 namedCurve: "P-256",
 },
 true,
 ["deriveKey", "deriveBits"]
 );

 return await window.crypto.subtle.deriveKey(
 {name: "ECDH", public: publicKey},
 privateKey,
 {name: "AES-GCM", length: 256},
 true,
 ["encrypt", "decrypt"]
 );
 };

 async encrypt(text) {

 const derivedKey = await this.deriveKey();

 const encodedText = new TextEncoder().encode(text);

 const iv = window.crypto.getRandomValues(new Uint8Array(12));

 const encryptedData = await window.crypto.subtle.encrypt(
 {name: "AES-GCM", iv: iv},
 derivedKey,
 encodedText
 );

 const uintArray = new Uint8Array(encryptedData);

 const string = String.fromCharCode.apply(null, uintArray);
 const iv_string = String.fromCharCode.apply(null, iv);

 const bDATA = btoa(string);
 const bIV = btoa(iv_string);

 return JSON.stringify({bDATA, bIV});
 };

 async decrypt(messageJSON) {
 try {

 const derivedKey = await this.deriveKey();

 const message = JSON.parse(messageJSON);
 const text = message.bDATA;
 const initializationVector = message.bIV;

 const iv_string = atob(initializationVector);
 const iv = new Uint8Array(
 [...iv_string].map((char) => char.charCodeAt(0))
 );

 const string = atob(text);
 const uintArray = new Uint8Array(
 [...string].map((char) => char.charCodeAt(0))
 );
 const algorithm = {
 name: "AES-GCM",
 iv: iv,
 };
 const decryptedData = await window.crypto.subtle.decrypt(
 algorithm,
 derivedKey,
 uintArray
 );

 return new TextDecoder().decode(decryptedData);
 } catch (e) {
 return `error decrypting message: ${e}`;
 }
 };

}

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

Сквозное шифрование (E2EE) в браузерном клиенте с ECDH и AES‑GCM снижает риск перехвата содержимого, но при контроле сервера разработчиком остаются серьёзные угрозы для безопасности веб приложений: подмена публичных ключей, модификация/внедрение клиентского JS, экспорт приватных ключей (JWK/derivedKey) и утечка метаданных. В представленном коде критично, что приватный ключ и производный ключ помечены как экспортируемые, отсутствует надёжный KDF/HKDF и нет механизма аутентификации/подтверждения ключа — это делает E2EE формально, но не практично защищённым в условиях недоверенного сервера.


Содержание


Краткий разбор угроз сквозного шифрования (E2EE) в веб‑приложении

Сама по себе схема ECDH + AES‑GCM обеспечивает конфиденциальность данных в канале между двумя криптографическими субъектами, но браузерная реализация добавляет слабые места. Почему? Потому что веб‑сервер не только передаёт/хранит сообщения, он доставляет и исполняемый код — а значит может изменить логику шифрования или получить ключи, если код позволяет это сделать (см. обзор ограничений браузерного E2EE). Для общих уязвимостей веб‑приложений полезно смотреть OWASP/локальные списки уязвимостей — они напоминают, что атаки часто идут не через алгоритм, а через инфраструктуру и конфигурацию (пример обзора про браузерный E2EE, OWASP‑обзор).


Технический анализ кода: ECDH и AES‑GCM

Ключевые моменты в вашем коде (строчный разбор и почему это риск).

  • Экспорт приватного ключа в generateKeyPair:
  • Вы вызываете generateKey(…, true, …) и экспортируете privateKey в JWK. Это даёт приватный ключ в виде сериализованного объекта — при любом перемещении этого JWK (на сервер, в локальное хранилище) — потеря безопасности.
  • Импорт ключей с extractable=true:
  • В deriveKey вы импортируете ключи с флагом extractable=true (в коде — третий параметр true при importKey). Это упрощает экспорт/утечку ключей.
  • Использование subtle.deriveKey напрямую:
  • deriveKey({name:‘ECDH’, public}, private, {name:‘AES-GCM’, length:256}, true, [‘encrypt’,‘decrypt’]) — платформа превращает ECDH‑общий секрет в AES‑ключ, но это неявный KDF: лучше явно использовать deriveBits + HKDF с salt/info, чтобы связать ключ с контекстом и избежать проблем совместимости/KDF‑атаки (пример использования ECDH в WebCrypto, замечания по WebCrypto‑особенностям см. в обсуждении на StackOverflow: https://stackoverflow.com/questions/75190267/crypto-subtle-decrypting-compact-jwe-with-ecdh-esa128kw-the-operation-failed).
  • AES‑GCM: IV‑политика и AAD:
  • Вы генерируете случайный 12‑байтный IV — хорошо. Но если тот же derivedKey используется много раз, важно гарантировать уникальность IV для каждого шифрования. Кроме того, вы не используете additionalData (AAD) — это помогает привязать контекст (senderId, sequence number) к шифротексту.
  • Сериализация бинарных данных:
  • Преобразование через String.fromCharCode.apply(null, uintArray) + btoa небезопасно для крупных буферов/UTF‑8; лучше использовать проверенные утилиты для base64 (или Buffer в Node). Неправильная сериализация может привести к битовым ошибкам и упростить атаки по побочному раскрытию.
  • Разглашение ошибок:
  • В catch вы возвращаете текст ошибки — это может дать много информации атакующему (детали реализации, алгоритмов).

Что может сделать недоверенный сервер

Если сервер контролируется разработчиком, но считается недоверенным, он способен:

  • Подменять/модифицировать клиентский JS при каждой загрузке — вставить экспфилятор приватного ключа или заменить логику шифрования. Это главный риск для браузерного E2EE (см. практические ограничения браузерной E2EE).
  • Выполнять «key substitution» (подмену публичных ключей) при распределении публичных ключей: сервер вернёт атакующий публичный ключ вместо настоящего — клиенты установят общий секрет с атакующим и утрата конфиденциальности произойдёт без видимых ошибок.
  • Попросить клиента экспортировать privateKeyJwk/derivedKey и переслать их на сервер — из‑за текущего exportable=true это тривиально.
  • Доставлять вредоносную полифил‑реализацию WebCrypto или подменять window.crypto.getRandomValues, тем самым нарушив криптографические предположения.
  • Просматривать и логировать метаданные: кто кому пишет, размеры сообщений, частота, часовое смещение — шифрование не скрывает эту информацию.
  • Выполнять downgrade‑атаки (смена кривой/алгоритма) если проверка параметров реализована на клиенте, но код может быть изменён.

Суммарно: сервер может превратить E2EE в фикцию, если клиент полагается на сервер при распределении ключей или при доставке исполняемого кода.


Угрозы при неповреждённом устройстве (остаются после исключения компромета)

Даже если устройство клиента не скомпрометировано, остаются:

  • Подмена публичных ключей (unauthenticated key exchange) — наиболее опасна.
  • Отсутствие гарантии целостности/аутентичности ключей: без подписи/сертификата вы не отличите настоящего публичного ключа от подставного.
  • Отсутствие подтверждения ключей (key confirmation) — вы не знаете, что другая сторона действительно получила ожидаемый ключ.
  • Отсутствие forward secrecy при использовании постоянных (static) ключей — компрометация приватного ключа компрометирует всю историю, если ключи не ротируются.
  • Метаданные и доступность: сервер всё ещё видит адресатов, размеры, временные метки; он может удалять/задерживать/модифицировать сообщения.
  • Групповые сценарии: простая пара ECDH плохо масштабируется для чатов — нужны другие протоколы (X3DH/Double Ratchet).

Для системной защиты этих рисков нужны дополнительные механизмы, а не только «правильный» AES‑GCM.


Приоритетные улучшения кода

Кратко — что исправить в первую очередь и почему.

Критично (сделать немедленно)

  • Никогда не экспортируйте приватный ключ. Генерируйте ключи с extractable: false и не отправляйте privateKeyJwk на сервер.
  • Делать derivedKey non‑extractable: при deriveKey установить extractable: false.
  • Не полагаться на implicit deriveKey без явного KDF: использовать deriveBits + HKDF (salt/info) для детерминированной и контекстно‑связанной генерации AES‑ключа.
  • Добавить аутентификацию публичных ключей: подписи долгосрочных ключей или верификация отпечатка ключа вне сервера (out‑of‑band) — иначе возможна подмена.
  • Включать AAD (additional authenticated data) в AES‑GCM: привязать senderId|receiverId|seq к шифротексту, чтобы защититься от перестановок/перепрокидываний.

Важно (близкий план)

  • Переход на эпхемерные ключи (session ephemeral) или ротация ключей для forward secrecy; лучше — использовать проверенные протоколы (X3DH + Double Ratchet).
  • Сделать derivedKey и privateKey хранение в защищённом хранилище (IndexedDB как CryptoKey structured clone) или использовать WebAuthn/криптографию платформы для защиты приватного ключа.
  • Минимизировать избыточную информация в ошибках; логируйте локально, не отправляйте стек‑трейсы на сервер.

Дополнительно

  • Вместо P‑256 рассмотреть X25519 (если поддерживается в целевых браузерах) — чаще предпочтителен для современных протоколов.
  • Использовать проверенные библиотеки/протоколы (Signal/libsignal, libsodium), а не «самописный» E2EE.

Примеры конкретных правок (фрагменты кода)

Ниже — компактные примеры идей, которые можно встроить в вашу структуру. Это не «drop‑in» библиотека, но иллюстрация улучшений.

  1. Генерация ключей — приватный ключ не экспортируем:
javascript
// Генерация: privateKey не экспортируем
const keyPair = await window.crypto.subtle.generateKey(
 { name: "ECDH", namedCurve: "P-256" },
 false, // privateKey non-extractable
 ["deriveKey", "deriveBits"]
);
const publicKeyJwk = await window.crypto.subtle.exportKey("jwk", keyPair.publicKey);
// Не экспортируем privateKey! Сохраняем CryptoKey в IndexedDB или в памяти.
  1. Явный KDF — deriveBits + HKDF, детерминированный salt (например, H(publicA||publicB)):
javascript
// helper: экспорт raw publicKey (ArrayBuffer)
async function exportRawPublicKey(pubKey) {
 return await window.crypto.subtle.exportKey("raw", pubKey);
}

async function deriveAesKey(privateKeyCryptoKey, remotePublicCryptoKey, localPublicCryptoKey) {
 // 1) общий секрет
 const shared = await window.crypto.subtle.deriveBits(
 { name: "ECDH", public: remotePublicCryptoKey },
 privateKeyCryptoKey,
 256
 );

 // 2) salt = SHA256(localPub || remotePub)
 const localRaw = await exportRawPublicKey(localPublicCryptoKey);
 const remoteRaw = await exportRawPublicKey(remotePublicCryptoKey);
 const concat = new Uint8Array([...new Uint8Array(localRaw), ...new Uint8Array(remoteRaw)]);
 const salt = await window.crypto.subtle.digest("SHA-256", concat);

 // 3) HKDF
 const hkKey = await window.crypto.subtle.importKey("raw", shared, "HKDF", false, ["deriveKey"]);
 const aesKey = await window.crypto.subtle.deriveKey(
 { name: "HKDF", hash: "SHA-256", salt: salt, info: new TextEncoder().encode("E2EE v1") },
 hkKey,
 { name: "AES-GCM", length: 256 },
 false, // не экспортируем derivedKey
 ["encrypt", "decrypt"]
 );
 return aesKey;
}
  1. Шифрование с AAD и безопасной базовой64‑сериализацией:
javascript
function uint8ToBase64(u8) {
 // безопасная chunked реализация
 const chunkSize = 0x8000;
 let i = 0;
 const chunks = [];
 while (i < u8.length) {
 const sub = u8.subarray(i, i + chunkSize);
 chunks.push(String.fromCharCode.apply(null, sub));
 i += chunkSize;
 }
 return btoa(chunks.join(''));
}

async function encrypt(aesKey, plaintextUint8, senderId, receiverId, seq) {
 const iv = window.crypto.getRandomValues(new Uint8Array(12));
 const aad = new TextEncoder().encode(`${senderId}|${receiverId}|${seq}`);
 const ct = await window.crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: aad }, aesKey, plaintextUint8);
 return {
 iv: uint8ToBase64(new Uint8Array(iv)),
 data: uint8ToBase64(new Uint8Array(ct))
 };
}
  1. Профилактика экспорта и ошибок:
  • При генерации/deriveKey ставьте extractable: false.
  • Не возвращайте в UI полный стек ошибок — лучше унифицированное сообщение.
  • Не храните privateKeyJwk на сервере и не импортируйте приватный JWK, полученный с сервера.

Архитектурные меры и альтернативы

Если сервер недоверен по определению, на уровне архитектуры нужно уменьшить его власть:

  • Перенести доверие в неподконтрольную серверу среду: нативное приложение или браузерное расширение с подписанным кодом исключит возможность серверной подмены JS при каждой загрузке. Многие проблемы браузерного E2EE происходят именно потому, что сервер поставляет исполняемый код (обзор проблем browser E2EE).
  • Использовать проверенные протоколы (Signal X3DH + Double Ratchet) и библиотеки, которые уже решают подпись ключей, подтверждение, ротацию и ratchet‑логику — это уменьшит шанс сделать ошибку в KDF/подтверждении ключа (общие best practices по app security).
  • Внедрить систему прозрачности ключей (key transparency) или публичный лог ключей, чтобы клиенты могли обнаруживать подмену публики.
  • Для сокрытия метаданных применять дополнительные меры (padding, mix‑серверы, отложенная доставка), но это сложнее и требует отдельной архитектуры.


Источники


Заключение

Сквозное шифрование (E2EE) с ECDH + AES‑GCM даёт хорошую криптографическую основу, но в браузерной реализации главное — не алгоритмы, а доверие к коду и распределению ключей. Если сервер недоверен, первоочередные шаги — не экспортировать приватные ключи, делать derivedKey неэкспортируемым, использовать deriveBits+HKDF с детерминированным salt/info, внедрить подпись/проверку публичных ключей (или подтверждение отпечатков) и включать AAD/политику ротации ключей. Без этих мер «сквозное шифрование» в веб‑приложении останется формальностью: E2EE работает только если доверие к клиентской реализации и к механизму распределения/аутентификации ключей восстановлено.

Авторы
Проверено модерацией
Модерация