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

Привет, Хабр!

Я написал статью и преждевременно её публикую. Изначально планировал писать после завершения проекта, но до окончания осталось ещё пару месяцев, поэтому решил не терять время и написать статью пока информация свежа в голове. К тому же, в большей степени пишу для себя. :) В одном из моих последних проектов, который я разрабатываю как open source, я реализовал сквозное (end-to-end) шифрование — аналогично тому, как это делают, например, WhatsApp или Telegram.

В этой статье мы углубимся в реализацию шифрования сообщений на стороне клиента с использованием JavaScript и Web Crypto API, разобрав практический пример, который будет в самом конце статьи.

Начнём с того, что если вы полный ноль в криптографии, то понять написанное здесь может быть непросто. Мне самому, несмотря на 10 лет опыта в разработке, пришлось поломать голову — всё, что здесь происходит, это чистая математика, о которой мы в этой статье говорить не будем :) Особо впечатлительные могут подумать, что это магия :)

Если вкратце, без сложных слов и терминов, попробую объяснить суть сквозного (end-to-end) шифрования

Магия шифрования в трёх ключах

Три ключа — это основа, на которой строится шифрование end-to-end. Обратите внимание на ключ по центру — это важно.

Фундамент, на котором всё держится, — это три ключа. Возвращайтесь к этому месту, если что-то окажется непонятным.

  1. Приватный ключ (Private Key): Хранится (в зашифрованном виде).

  2. Публичный ключ (Public Key): Доступен абсолютно всем.

  3. Общий секретный ключ (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():

  1. Преобразование ключей: Ключи из Base64 переводятся в ArrayBuffer.

  2. Импорт приватного ключа: Ваш приватный ключ импортируется в формате pkcs8. Указывается, что это ключ ECDH на кривой P-256 и он будет использоваться для deriveBits (вычисления общего секрета).

  3. Импорт публичного ключа собеседника: Публичный ключ собеседника импортируется в формате spki.

  4. Вычисление общего секрета (deriveBits): Это ключевой шаг ECDH. Используя ваш приватный ключ и публичный ключ собеседника, deriveBits вычисляет общий секретный набор бит (sharedBits). Этот секрет будет одинаковым у вас и вашего собеседника, если они используют свои соответствующие приватные ключи и публичные ключи друг друга.

  5. Хеширование общего секрета (digest): sharedBits хешируются с помощью SHA-256. Это распространенная практика для преобразования вывода deriveBits в криптографически стойкий ключ нужной длины для симметричного шифра (в данном случае AES). Этот шаг также служит как KDF (Key Derivation Function).

  6. Импорт ключа AES (importKey): Полученный хеш (hashed) импортируется как "сырой" (raw) ключ для алгоритма AES-GCM. Этот ключ (this.aesKey) теперь готов к использованию для шифрования и дешифрования сообщений.

После успешного выполнения this.aesKey будет содержать объект CryptoKey, готовый к работе.

Метод encrypt(plaintext): Шифрование сообщения

Разбор шагов в encrypt():

  1. Генерация IV (Initialization Vector): Создается случайный 12-байтный IV. Напоминаем, он должен быть уникальным для каждого шифрования этим же ключом.

  2. Кодирование текста: Сообщение преобразуется из строки JavaScript в Uint8Array (последовательность байт в кодировке UTF-8) с помощью TextEncoder.

  3. Шифрование: crypto.subtle.encrypt выполняет шифрование данных с использованием AES-GCM, нашего this.aesKey и сгенерированного iv.

  4. Возврат результата: Зашифрованные данные и IV (оба в Base64) возвращаются как объект. IV необходимо передать получателю вместе с шифротекстом, так как он потребуется для дешифрования.

Метод decrypt(cipherBase64, ivBase64): Дешифрование сообщения

Разбор шагов в decrypt():

  1. Преобразование данных: Полученные шифротекст и IV (в Base64) преобразуются обратно в ArrayBuffer. Обратите внимание, что для crypto.subtle.decrypt параметр iv должен быть TypedArray (например, Uint8Array), поэтому мы передаем new Uint8Array(ivBuffer).

  2. Дешифрование: crypto.subtle.decrypt выполняет дешифрование. Важно, что AES-GCM не только расшифрует данные, но и проверит их целостность и аутентичность, используя тот же aesKey и iv, которые использовались при шифровании. Если данные были подделаны, ключ не тот или IV не тот, метод decrypt вернет ошибку (отклонит Promise).

  3. Декодирование текста: Успешно расшифрованные байты преобразуются обратно в читаемую строку с помощью TextDecoder.

4. Как это работает вместе: Концептуальный поток

  1. Генерация ключевых пар:

    • Пользователь А генерирует свою пару ECDH-ключей (публичный PkA и приватный SkA).

    • Пользователь Б делает то же самое (публичный PkB и приватный SkB).

    • Этот шаг в приведенном коде ChatCrypto не показан, но показан ниже в секции примеры (он выполняется один раз, например, при регистрации пользователя), он предшествует использованию класса. Web Crypto API имеет метод crypto.subtle.generateKey для этого. Приватный ключ Sk должен храниться надежно и быть зашифрован паролем пользователя.

  2. Обмен публичными ключами:

    • Пользователь А передает свой публичный ключ PkA пользователю Б.

    • Пользователь Б передает свой публичный ключ PkB пользователю А.

    • Этот обмен должен быть надежным, чтобы избежать атаки "человек посередине" (Man-in-the-Middle, MitM). Например, через защищенный сервер или путем верификации отпечатков ключей.

  3. Инициализация 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.

  4. Обмен сообщениями:

    • Пользователь А шифрует сообщение для Б: 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 служит хорошим отправным примером такой реализации. Помните о важности безопасной генерации, хранения приватных ключей и надежного обмена публичными ключами для построения безопасной системы.