Конверт шифрования для нескольких получателей

В криптографических системах с асимметричными ключами возникает фундаментальное ограничение: один зашифрованный блок данных, созданный с использованием публичного ключа, может быть расшифрован только соответствующим приватным ключом. Это удобно для сценариев «один отправитель — один получатель», но становится неудобным, когда сообщение должно быть доставлено группе участников.

Решение этой задачи строится на гибридной схеме, где используется сочетание симметричного и асимметричного шифрования. В контексте TweetNaCl.js / nacl.js такая модель часто реализуется как «конверт шифрования» (envelope encryption).


Базовая идея конверта

Конверт состоит из двух уровней:

  1. Данные шифруются симметричным ключом

    • быстрый алгоритм (nacl.secretbox)
    • один ключ на сообщение
  2. Симметричный ключ шифруется для каждого получателя отдельно

    • используется nacl.box или nacl.box.seal
    • каждый получатель получает свою копию ключа

Итоговая структура:

  • зашифрованное сообщение (ciphertext)
  • список зашифрованных ключей (key envelopes)
  • служебные данные (nonce, алгоритмические параметры)

Почему именно так устроено

Symmetric encryption (secretbox) значительно быстрее, чем асимметрическое шифрование. Но secretbox требует общего секретного ключа.

Ассиметрическая криптография (box) позволяет безопасно передавать ключ, но дорогая по вычислениям.

Поэтому:

  • данные → secretbox
  • ключ → box (много раз, для каждого получателя)

Используемые primitives TweetNaCl.js

Основные функции:

  • nacl.randomBytes(n) — генерация случайных данных
  • nacl.secretbox(message, nonce, key) — симметричное шифрование
  • nacl.secretbox.open(cipher, nonce, key) — расшифрование
  • nacl.box(message, nonce, publicKey, secretKey) — асимметричное шифрование
  • nacl.box.open(cipher, nonce, publicKey, secretKey) — расшифрование
  • nacl.box.keyPair() — генерация пары ключей
  • nacl.box.seal() — упрощённое шифрование без nonce (NaCl sealed box)

Структура данных конверта

Практическая реализация обычно сериализует конверт в JSON или бинарный формат:

{
  "ciphertext": "base64...",
  "nonce": "base64...",
  "keys": [
    {
      "recipient": "userA",
      "encryptedKey": "base64..."
    },
    {
      "recipient": "userB",
      "encryptedKey": "base64..."
    }
  ]
}

Генерация конверта: пошаговый процесс

1. Генерация симметричного ключа

import nacl from "tweetnacl";

const symmetricKey = nacl.randomBytes(nacl.secretbox.keyLength);

Этот ключ будет использоваться для шифрования основного сообщения.


2. Шифрование сообщения

const nonce = nacl.randomBytes(nacl.secretbox.nonceLength);

const messageUint8 = new TextEncoder().encode("Секретное сообщение для группы");

const ciphertext = nacl.secretbox(messageUint8, nonce, symmetricKey);

3. Подготовка получателей

Каждый получатель имеет ключевую пару:

const alice = nacl.box.keyPair();
const bob = nacl.box.keyPair();
const carol = nacl.box.keyPair();

4. Шифрование симметричного ключа для каждого получателя

Здесь используется nacl.box. Для этого нужен ephemeral key sender-а:

const sender = nacl.box.keyPair();

Теперь шифруем ключ:

function encryptKeyForRecipient(recipientPublicKey, keyToEncrypt, senderSecretKey) {
  const nonce = nacl.randomBytes(nacl.box.nonceLength);

  const encrypted = nacl.box(
    keyToEncrypt,
    nonce,
    recipientPublicKey,
    senderSecretKey
  );

  return {
    nonce,
    encrypted
  };
}

5. Формирование списка ключей

const recipients = [
  { id: "alice", key: alice.publicKey },
  { id: "bob", key: bob.publicKey },
  { id: "carol", key: carol.publicKey }
];

const encryptedKeys = recipients.map(r => {
  const { nonce, encrypted } = encryptKeyForRecipient(
    r.key,
    symmetricKey,
    sender.secretKey
  );

  return {
    recipient: r.id,
    nonce,
    encryptedKey: encrypted
  };
});

6. Итоговый конверт

const envelope = {
  senderPublicKey: sender.publicKey,
  nonce,
  ciphertext,
  encryptedKeys
};

Расшифровка конверта

Получатель выполняет обратные действия:

1. Поиск своего ключевого блока

function findKey(envelope, recipientId) {
  return envelope.encryptedKeys.find(k => k.recipient === recipientId);
}

2. Расшифровка симметричного ключа

const keyPacket = findKey(envelope, "alice");

const symmetricKey = nacl.box.open(
  keyPacket.encryptedKey,
  keyPacket.nonce,
  envelope.senderPublicKey,
  alice.secretKey
);

3. Расшифровка сообщения

const decrypted = nacl.secretbox.open(
  envelope.ciphertext,
  envelope.nonce,
  symmetricKey
);

const message = new TextDecoder().decode(decrypted);

Альтернативный подход: sealed box для ключа

TweetNaCl.js поддерживает упрощённый режим:

const encryptedKey = nacl.box.seal(symmetricKey, recipientPublicKey);

Минус:

  • нельзя явно управлять nonce
  • хуже подходит для аудита и кастомных протоколов

Плюс:

  • проще код
  • меньше ошибок

Оптимизация для большого числа получателей

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

  • линейное увеличение размера конверта
  • нагрузка на шифрование ключа

Решения:

1. Групповая иерархия ключей

  • создаётся group key
  • он шифруется один раз через root key distribution

2. Использование Tree-based key distribution

  • логарифмическое количество операций
  • используется в системах вещания

3. Кэширование decrypted symmetric key

  • ключ хранится в памяти до конца сессии

Форматирование и сериализация

TweetNaCl работает с Uint8Array, поэтому требуется кодирование:

function encodeBase64(u8) {
  return Buffer.from(u8).toString("base64");
}

function decodeBase64(str) {
  return new Uint8Array(Buffer.from(str, "base64"));
}

Применение:

const serialized = {
  ciphertext: encodeBase64(ciphertext),
  nonce: encodeBase64(nonce),
  encryptedKeys: encryptedKeys.map(k => ({
    recipient: k.recipient,
    nonce: encodeBase64(k.nonce),
    encryptedKey: encodeBase64(k.encryptedKey)
  }))
};

Безопасностные свойства модели

Конверт шифрования обеспечивает:

  • конфиденциальность данных через secretbox
  • индивидуальный контроль доступа через box
  • forward secrecy при правильной реализации ephemeral sender keys
  • независимость компрометации получателей

Если приватный ключ одного участника скомпрометирован:

  • расшифровать ключи других получателей невозможно
  • данные остаются защищёнными

Типичные ошибки реализации

Повторное использование nonce

В secretbox это критическая ошибка:

  • приводит к утечке XOR структуры сообщений
  • делает шифр уязвимым

Использование одного nonce для всех получателей

Для box каждый ключевой пакет должен иметь свой nonce.


Отсутствие проверки целостности структуры

Важно проверять:

  • существование ключа для получателя
  • корректность длины ключей
  • валидность Uint8Array

Практическая архитектура в приложениях

В реальных системах конверт часто используется в:

  • корпоративных мессенджерах
  • защищённых email-системах
  • распределённых storage системах
  • P2P протоколах

Типичная схема:

  1. клиент генерирует message key
  2. шифрует payload
  3. сервер распределяет encrypted keys
  4. каждый клиент расшифровывает локально

Расширение модели: метаданные и подписи

Конверт часто дополняется:

  • цифровой подписью отправителя (nacl.sign)
  • временными метками
  • идентификаторами версий протокола

Пример:

const signed = nacl.sign(
  messageUint8,
  senderSigningSecretKey
);

Масштабирование и производительность

При росте числа получателей:

  • сложность шифрования: O(n)
  • размер payload: O(n)

Оптимизации:

  • batch encryption ключей
  • reuse ephemeral sender key
  • hardware acceleration (WebCrypto в гибридных системах)

Итоговая модель поведения системы

Конверт шифрования в TweetNaCl.js можно рассматривать как композицию двух уровней:

  • быстрый симметричный слой для данных
  • гибкий асимметричный слой для доставки ключей

Такая структура остаётся базовым строительным блоком большинства современных криптографических протоколов, построенных поверх NaCl.