Уязвимости E2EE сквозного шифрования в веб-приложениях ECDH AES-GCM
Потенциальные уязвимости безопасности при использовании сквозного шифрования E2EE в веб-приложениях с ECDH для обмена ключами и AES-GCM. Анализ кода, угрозы от недоверенного сервера, улучшения для повышения безопасности без компрометации устройства.
Каковы потенциальные уязвимости в безопасности при использовании сквозного шифрования (E2EE) в веб-приложении, где сервер контролируется разработчиком, но считается недоверенным лицом? В частности, какие риски существуют в предложенной реализации, использующей ECDH для обмена ключами и AES-GCM для шифрования, с учетом следующего JS-кода на стороне клиента?
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) в веб‑приложении
Сама по себе схема 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» библиотека, но иллюстрация улучшений.
- Генерация ключей — приватный ключ не экспортируем:
// Генерация: 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 или в памяти.
- Явный KDF — deriveBits + HKDF, детерминированный salt (например, H(publicA||publicB)):
// 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;
}
- Шифрование с AAD и безопасной базовой64‑сериализацией:
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))
};
}
- Профилактика экспорта и ошибок:
- При генерации/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 и практических рисков: https://thomasbandt.com/browser-based-end-to-end-encryption-overview
- Практики ECDH в WebCrypto (пример): https://asecuritysite.com/webcrypto/crypt_ecdh_enc2
- Замечания по совместимости WebCrypto и кейсам: https://stackoverflow.com/questions/75190267/crypto-subtle-decrypting-compact-jwe-with-ecdh-esa128kw-the-operation-failed
- Предупреждение про повторное использование IV и практики AES‑GCM: https://mojoauth.com/keypair-generation/generate-keypair-using-ecdh-with-javascript/
- OWASP / вводные по типовым уязвимостям веб‑приложений: https://skillbox.ru/media/code/owasp-top-10-samye-rasprostranyennye-uyazvimosti-vebprilozheniy/
- Официальный реестр типовых уязвимостей (CWE и пр.): https://bdu.fstec.ru/webvulns
- Практики безопасности приложений и E2EE в приложениях: https://www.securitycompass.com/blog/application-security-best-practices/
- Разбор атак на веб‑системы и подмены: https://www.securityvision.ru/blog/ataki-na-veb-sistemy-po-metodologii-owasp-top-10/
- ECDH примеры и заметки по Node.js: https://www.w3schools.com/nodejs/ref_ecdh.asp
Заключение
Сквозное шифрование (E2EE) с ECDH + AES‑GCM даёт хорошую криптографическую основу, но в браузерной реализации главное — не алгоритмы, а доверие к коду и распределению ключей. Если сервер недоверен, первоочередные шаги — не экспортировать приватные ключи, делать derivedKey неэкспортируемым, использовать deriveBits+HKDF с детерминированным salt/info, внедрить подпись/проверку публичных ключей (или подтверждение отпечатков) и включать AAD/политику ротации ключей. Без этих мер «сквозное шифрование» в веб‑приложении останется формальностью: E2EE работает только если доверие к клиентской реализации и к механизму распределения/аутентификации ключей восстановлено.