Как мессенджеры шифруют сообщения (end-to-end) на самом деле
Привет, Хабр!
Я написал статью и преждевременно её публикую. Изначально планировал писать после завершения проекта, но до окончания осталось ещё пару месяцев, поэтому решил не терять время и написать статью пока информация свежа в голове. К тому же, в большей степени пишу для себя. :) В одном из моих последних проектов, который я разрабатываю как 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
служит хорошим отправным примером такой реализации. Помните о важности безопасной генерации, хранения приватных ключей и надежного обмена публичными ключами для построения безопасной системы.