Привет, Хабр!
Я написал статью и преждевременно её публикую. Изначально планировал писать после завершения проекта, но до окончания осталось ещё пару месяцев, поэтому решил не терять время и написать статью пока информация свежа в голове. К тому же, в большей степени пишу для себя. 🙂 В одном из моих последних проектов, который я разрабатываю как open source, я реализовал сквозное (end-to-end) шифрование — аналогично тому, как это делают, например, WhatsApp или Telegram.
В этой статье мы углубимся в реализацию шифрования сообщений на стороне клиента с использованием JavaScript и Web Crypto API, разобрав практический пример, который будет в самом конце статьи.
Начнём с того, что если вы полный ноль в криптографии, то понять написанное здесь может быть непросто. Мне самому, несмотря на 10 лет опыта в разработке, пришлось поломать голову — всё, что здесь происходит, это чистая математика, о которой мы в этой статье говорить не будем 🙂 Особо впечатлительные могут подумать, что это магия 🙂
Если вкратце, без сложных слов и терминов, попробую объяснить суть сквозного (end-to-end) шифрования
Магия шифрования в трёх ключах
Фундамент, на котором всё держится, — это три ключа. Возвращайтесь к этому месту, если что-то окажется непонятным.
-
Приватный ключ (Private Key): Хранится (в зашифрованном виде).
-
Публичный ключ (Public Key): Доступен абсолютно всем.
-
Общий секретный ключ (Shared Secret / Symmetric Key): Генерируется на основе вашего приватного ключа + публичного ключа вашего собеседника. Именно этот ключ используется для непосредственного шифрования и дешифрования сообщений.
Комбинация вашего приватного ключа + публичного ключа вашего собеседника позволяет получить общий секретный ключ (в нашем примере ниже это будет ключ AES). Благодаря этому общему секретному ключу вы можете шифровать и расшифровывать сообщения.
Приватный и публичный ключи можно хранить в базе данных, но есть нюанс с приватным ключом. Сам приватный ключ не рекомендуется хранить в открытом виде; его нужно дополнительно зашифровать паролем пользователя или любым другим ключевым словом (в рамках мессенджера это, как правило, пароль пользователя). Публичный ключ мы храним в открытом виде.
Общий секретный ключ (тот, что в коде нижеthis.aesKey) мы не храним в базе данных. Он генерируется (вычисляется) каждый раз при инициализации чата с конкретным контактом. Здесь может возникнуть недоумение: как же мы будем расшифровывать сообщения, если этот ключ не хранится, а генерируется заново? В этом и заключается «магия» асимметричного шифрования и протокола обмена ключами.
Когда вы открываете чат с контактом, ваш клиент заново вычисляет этот общий секретный ключ, как вы уже знаете вот таким образом (Ваш приватный ключ + Публичный ключ вашего контакта = Общий секретный ключ). С помощью этого ключа вы шифруете новые сообщения и расшифровываете все предыдущие сообщения в этом чате, так как они были зашифрованы тем же самым общим секретным ключом. Голову можно ломать долго и безрезультатно, пока не поймем, что такое асимметричное шифрование и протоколы обмена ключами.
Асимметричное шифрование и ECDH
Асимметричное шифрование использует пару ключей: публичный (public) и приватный (private). Публичный ключ можно свободно распространять, в то время как приватный ключ должен храниться его владельцем в секрете то есть в зашифрованном виде.
ECDH (Elliptic Curve Diffie-Hellman) – это протокол обмена ключами, основанный на математике эллиптических кривых. Он позволяет двум сторонам, каждая из которых имеет свою пару ECDH-ключей (приватный и публичный), установить общий секретный ключ через незащищенный канал. Важно, что третья сторона, даже перехватив их публичные ключи, не сможет вычислить этот общий секрет. В нашем примере используется кривая P-256 – популярный и надежный стандарт.
Думаю, немногие поняли то, что только что прочитали. Всё, что вам нужно понять на данном этапе, – это то, что технология работает 🙂 Позже пазл сложится, возможно, после повторного прочтения. А теперь немного о встроенных технологиях браузера.
Web Crypto API
Web Crypto API – это встроенный в браузеры интерфейс JavaScript, предоставляющий доступ к низкоуровневым криптографическим примитивам. Он позволяет выполнять такие операции, как хеширование, генерация подписей, шифрование и дешифрование. Использование Web Crypto API предпочтительнее сторонних библиотек для основных криптографических операций, так как оно часто аппаратно ускорено и тщательно проверено на безопасность. Все операции Web Crypto API асинхронны и возвращают Promise.
А теперь перейдем к практическому анализу. Я создал класс ChatCrypto, который мы рассмотрим подробнее:
class ChatCrypto { constructor(myPrivateKeyBase64, theirPublicKeyBase64) { this.myPrivateKeyBase64 = myPrivateKeyBase64; this.theirPublicKeyBase64 = theirPublicKeyBase64; this.aesKey = null; // Здесь будет храниться общий симметричный ключ AES } static base64ToArrayBuffer(base64) { const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes.buffer; } static arrayBufferToBase64(buffer) { const bytes = new Uint8Array(buffer); let binary = ''; for (let b of bytes) { binary += String.fromCharCode(b); } return btoa(binary); } init() { // Преобразуем ключи из Base64 в ArrayBuffer const privateRaw = ChatCrypto.base64ToArrayBuffer(this.myPrivateKeyBase64); const publicRaw = ChatCrypto.base64ToArrayBuffer(this.theirPublicKeyBase64); // Замечание: Следующие строки для разбора publicRaw на x и y координаты, // и сборка uncompressedPoint могут быть специфичны для определенного формата // представления "сырого" открытого ключа. Если publicRaw уже в формате SPKI, // они могут не понадобиться, так как crypto.subtle.importKey("spki", ...) // ожидает стандартную структуру. // const x = publicRaw.slice(0, publicRaw.byteLength / 2); // const y = publicRaw.slice(publicRaw.byteLength / 2); // const uncompressedPoint = new Uint8Array([0x04, ...new Uint8Array(x), ...new Uint8Array(y)]); // Импортируем наш приватный ключ return crypto.subtle.importKey( "pkcs8", // Формат приватного ключа (стандартный) privateRaw, { name: "ECDH", namedCurve: "P-256" }, // Алгоритм и параметры false, // Неэкспортируемый ["deriveBits"] // Разрешенное использование: для вывода бит (общего секрета) ).then(privateKey => { // Импортируем публичный ключ собеседника return crypto.subtle.importKey( "spki", // Формат публичного ключа (стандартный) publicRaw, { name: "ECDH", namedCurve: "P-256" }, false, // Неэкспортируемый [] // Для публичного ключа в ECDH здесь специфические использования не нужны ).then(publicKey => { // 4. Вычисляем общий секрет (deriveBits) return crypto.subtle.deriveBits( { name: "ECDH", public: publicKey }, // Указываем публичный ключ собеседника privateKey, // Наш приватный ключ 256 // Длина выводимого секрета в битах ); }); }).then(sharedBits => { // Хешируем общий секрет для получения ключа AES (используем SHA-256 как KDF) return crypto.subtle.digest("SHA-256", sharedBits); }).then(hashed => { // Импортируем хешированный секрет как ключ AES-GCM return crypto.subtle.importKey( "raw", // Формат "сырых" байт hashed, // Хешированный секрет { name: "AES-GCM" }, // Алгоритм симметричного шифрования false, // Неэкспортируемый ["encrypt", "decrypt"] // Разрешенные использования: шифрование и дешифрование ); }).then(aesKey => { this.aesKey = aesKey; // ✅ ВАЖНО! Сохраняем полученный ключ AES return true; // Сигнализируем об успешной инициализации }); } encrypt(plaintext) { if (!this.aesKey) return Promise.reject("ChatCrypto not initialized"); // Генерируем уникальный вектор инициализации (IV) const iv = crypto.getRandomValues(new Uint8Array(12)); // 12 байт (96 бит) рекомендуется для AES-GCM // Преобразуем текстовое сообщение в байты (UTF-8) const encoded = new TextEncoder().encode(plaintext); // Шифруем данные return crypto.subtle.encrypt( { name: "AES-GCM", iv: iv }, // Алгоритм и IV this.aesKey, // Наш общий ключ AES encoded // Данные для шифрования ).then(encrypted => { // 4. Возвращаем IV и зашифрованные данные (в Base64 для удобства передачи) return { iv: ChatCrypto.arrayBufferToBase64(iv), data: ChatCrypto.arrayBufferToBase64(encrypted) }; }); } decrypt(cipherBase64, ivBase64) { if (!this.aesKey) return Promise.reject("ChatCrypto not initialized"); // Преобразуем шифротекст и IV из Base64 в ArrayBuffer const encrypted = ChatCrypto.base64ToArrayBuffer(cipherBase64); const ivBuffer = ChatCrypto.base64ToArrayBuffer(ivBase64); // Дешифруем данные return crypto.subtle.decrypt( { name: "AES-GCM", iv: new Uint8Array(ivBuffer) }, // Алгоритм и IV (должен быть TypedArray) this.aesKey, // Тот же общий ключ AES encrypted // Зашифрованные данные ).then(decrypted => { // Преобразуем расшифрованные байты обратно в строку return new TextDecoder().decode(decrypted); }); } }
Конструктор constructor() и метод base64ToArrayBuffer():
-
Конструктор принимает ваш приватный ключ и публичный ключ собеседника в формате Base64. Base64 – это способ кодирования бинарных данных в текстовую строку, удобный для передачи или хранения.
-
this.aesKeyинициализируется какnullи будет заполнен после успешного выполнения методаinit(). -
Статические методы
base64ToArrayBufferиarrayBufferToBase64служат для преобразования данных между строками Base64 иArrayBuffer(формат, с которым работает Web Crypto API).
Метод init(): Установление общего ключа AES
Это сердце нашего класса, где происходит «магия» ECDH и создается общий ключ для симметричного шифрования.
Разбор шагов в init():
-
Преобразование ключей: Ключи из Base64 переводятся в
ArrayBuffer. -
Импорт приватного ключа: Ваш приватный ключ импортируется в формате
pkcs8. Указывается, что это ключ ECDH на кривой P-256 и он будет использоваться дляderiveBits(вычисления общего секрета). -
Импорт публичного ключа собеседника: Публичный ключ собеседника импортируется в формате
spki. -
Вычисление общего секрета (
deriveBits): Это ключевой шаг ECDH. Используя ваш приватный ключ и публичный ключ собеседника,deriveBitsвычисляет общий секретный набор бит (sharedBits). Этот секрет будет одинаковым у вас и вашего собеседника, если они используют свои соответствующие приватные ключи и публичные ключи друг друга. -
Хеширование общего секрета (
digest):sharedBitsхешируются с помощью SHA-256. Это распространенная практика для преобразования выводаderiveBitsв криптографически стойкий ключ нужной длины для симметричного шифра (в данном случае AES). Этот шаг также служит как KDF (Key Derivation Function). -
Импорт ключа AES (
importKey): Полученный хеш (hashed) импортируется как «сырой» (raw) ключ для алгоритма AES-GCM. Этот ключ (this.aesKey) теперь готов к использованию для шифрования и дешифрования сообщений.
После успешного выполнения this.aesKey будет содержать объект CryptoKey, готовый к работе.
Метод encrypt(plaintext): Шифрование сообщения
Разбор шагов в encrypt():
-
Генерация IV (Initialization Vector): Создается случайный 12-байтный IV. Напоминаем, он должен быть уникальным для каждого шифрования этим же ключом.
-
Кодирование текста: Сообщение преобразуется из строки JavaScript в
Uint8Array(последовательность байт в кодировке UTF-8) с помощьюTextEncoder. -
Шифрование:
crypto.subtle.encryptвыполняет шифрование данных с использованием AES-GCM, нашегоthis.aesKeyи сгенерированногоiv. -
Возврат результата: Зашифрованные данные и IV (оба в Base64) возвращаются как объект. IV необходимо передать получателю вместе с шифротекстом, так как он потребуется для дешифрования.
Метод decrypt(cipherBase64, ivBase64): Дешифрование сообщения
Разбор шагов в decrypt():
-
Преобразование данных: Полученные шифротекст и IV (в Base64) преобразуются обратно в
ArrayBuffer. Обратите внимание, что дляcrypto.subtle.decryptпараметрivдолжен бытьTypedArray(например,Uint8Array), поэтому мы передаемnew Uint8Array(ivBuffer). -
Дешифрование:
crypto.subtle.decryptвыполняет дешифрование. Важно, что AES-GCM не только расшифрует данные, но и проверит их целостность и аутентичность, используя тот жеaesKeyиiv, которые использовались при шифровании. Если данные были подделаны, ключ не тот или IV не тот, методdecryptвернет ошибку (отклонитPromise). -
Декодирование текста: Успешно расшифрованные байты преобразуются обратно в читаемую строку с помощью
TextDecoder.
4. Как это работает вместе: Концептуальный поток
-
Генерация ключевых пар:
-
Пользователь А генерирует свою пару ECDH-ключей (публичный
PkAи приватныйSkA). -
Пользователь Б делает то же самое (публичный
PkBи приватныйSkB). -
Этот шаг в приведенном коде
ChatCryptoне показан, но показан ниже в секции примеры (он выполняется один раз, например, при регистрации пользователя), он предшествует использованию класса. Web Crypto API имеет методcrypto.subtle.generateKeyдля этого. Приватный ключSkдолжен храниться надежно и быть зашифрован паролем пользователя.
-
-
Обмен публичными ключами:
-
Пользователь А передает свой публичный ключ
PkAпользователю Б. -
Пользователь Б передает свой публичный ключ
PkBпользователю А. -
Этот обмен должен быть надежным, чтобы избежать атаки «человек посередине» (Man-in-the-Middle, MitM). Например, через защищенный сервер или путем верификации отпечатков ключей.
-
-
Инициализация
ChatCryptoи вычисление общего секретного ключа:-
У пользователя А:
const cryptoA = new ChatCrypto(SkA_base64, PkB_base64); await cryptoA.init(); -
У пользователя Б:
const cryptoB = new ChatCrypto(SkB_base64, PkA_base64); await cryptoB.init(); -
В результате у обоих (
cryptoA.aesKeyиcryptoB.aesKey) будет вычислен одинаковый симметричный ключ AES.
-
-
Обмен сообщениями:
-
Пользователь А шифрует сообщение для Б:
const { iv, data } = await chatCryptoA.encrypt("Привет, Б!");Затем А отправляет объект{ iv, data }пользователю Б. -
Пользователь Б получает
{ iv, data }и дешифрует:const message = await chatCryptoB.decrypt(data, iv); // message будет "Привет, Б!"
-
Пример: Шифрование текста encrypt()
// Иницилизация класса let chatCrypto = new ChatCrypto( "Мой_приватный_ключ" , "Публичный_ключ_контакта" ); // Запускаем chatCrypto.init().then(() => { chatCrypto.encrypt("Текст").then(result => { // Выведется зашифрованный текст console.log(result); }); });
Пример: Расшифровка текста decrypt()
// Иницилизация класса let chatCrypto = new ChatCrypto( "Мой_приватный_ключ" , "Публичный_ключ_контакта" ); // Запускаем chatCrypto.init() .then( () => chatCrypto.decrypt("Зашифрованный_текст", "Векторный_ключ") ) .then(result => { // Выведется расшифрованный текст console.log(result); })
Заключение
Мы рассмотрели, как можно реализовать надежное сквозное шифрование сообщений в JavaScript с использованием Web Crypto API. Комбинация ECDH для безопасного обмена ключами и AES-GCM для эффективного и аутентифицированного шифрования данных является мощным и современным подходом. Класс ChatCrypto служит хорошим отправным примером такой реализации. Помните о важности безопасной генерации, хранения приватных ключей и надежного обмена публичными ключами для построения безопасной системы.
ссылка на оригинал статьи https://habr.com/ru/articles/914118/
Добавить комментарий