Шифрование данных на стороне клиента с помощью WebCrypto

Веб-функции: Web Cryptography

Используйте Web Cryptography API (crypto.subtle), чтобы устанавливать общие секреты в ненадежных сетях с помощью квантово-устойчивых механизмов инкапсуляции ключей (KEM) или классического согласования ключей, а также шифровать конфиденциальные данные в браузере с помощью аутентифицированного шифрования со связанными данными (AEAD). Чтобы подписывать и проверять сообщения или артефакты без шифрования, ознакомьтесь со статьей Как подписывать и проверять данные с помощью WebCrypto.

Сквозной процесс конвертного шифрования состоит из двух этапов:

  • Однократная настройка получателя (шаги 1–2). Получатель генерирует пару ключей, сохраняет CryptoKeyPair в IndexedDB для будущих сеансов и экспортирует открытый ключ ('raw-public'), чтобы поделиться им с отправителями.
  • Шифрование отдельных сообщений (шаги 3–4). Для каждого пакета данных отправитель импортирует открытый ключ получателя, вызывает encapsulateKey(), чтобы сгенерировать новый одноразовый общий ключ и капсулу зашифрованного текста, шифрует пакет данных с помощью нового 12-байтового вектора инициализации и отправляет { capsule, iv, ciphertext } получателю.

1. Выберите алгоритм KEM и сгенерируйте пару ключей получателя

В отличие от протокола Диффи – Хеллмана (deriveKey()), в котором обе стороны объединяют свои ключи, механизм инкапсуляции ключей (KEM) использует открытый ключ получателя, чтобы сгенерировать случайный общий секрет и капсулу зашифрованного текста за один вызов encapsulateKey(). Получатель восстанавливает тот же общий ключ из капсулы зашифрованного текста с помощью decapsulateKey().

По умолчанию используйте 'MLKEM768-X25519'. Этот алгоритм сочетает постквантовый ML-KEM (стандартизированный в NIST FIPS 203) с классическим 'X25519', поэтому общий секрет остается защищенным, пока работает любой из алгоритмов. Выберите автономный режим 'ML-KEM-768', если правила требуют только выходных данных FIPS 203 без гибридного компонента, или 'ML-KEM-1024', если правила требуют соответствия категории 5 NIST (CNSA 2.0).

Название алгоритма Уровень безопасности и сценарий использования Открытый ключ ('raw-public') Частный сид ('raw-seed') Размер зашифрованного текста
'MLKEM768-X25519' Гибридный постквантовый ('ML-KEM-768') + классический ('X25519'); рекомендуется по умолчанию 1216 байт 32 байта 1120 байт
'ML-KEM-768' Категория 3 NIST (безопасность ~192-битного ключа); отдельный постквантовый KEM 1184 байта 64 байта 1088 байт
'ML-KEM-1024' Категория 5 NIST (примерно 256-битная защита); максимальный запас безопасности 1568 байт 64 байта 1568 байт
  • В качестве названия алгоритма передайте 'MLKEM768-X25519', 'ML-KEM-768' или 'ML-KEM-1024' (псевдонимы, такие как 'X-Wing' или 'X25519MLKEM768', а также 'ML-KEM-512', вызывают ошибку NotSupportedError).
  • При создании пары ключей KEM передайте extractable: false и ['encapsulateKey', 'decapsulateKey'] (или ['encapsulateBits', 'decapsulateBits']). Неизвлекаемые объекты CryptoKey и CryptoKeyPair можно сохранять между сеансами непосредственно в IndexedDB с помощью структурированного клонирования (objectStore.put(recipientKeyPair, 'recipient-kem-key')), не экспортируя байты закрытого ключа:
const recipientKeyPair = await crypto.subtle.generateKey(
  'MLKEM768-X25519',
  false,
  ['encapsulateKey', 'decapsulateKey'],
);

2. Экспорт и импорт ключей

Современные алгоритмы WebCrypto ('MLKEM768-X25519', 'ML-KEM-768', 'ML-KEM-1024' и 'ChaCha20-Poly1305') отклоняют устаревший формат 'raw' в importKey() и exportKey() с ошибкой NotSupportedError. Всегда указывайте явный формат ключа:

  • 'raw-public' – байты открытого ключа (1216, 1184 или 1568 байт). При импорте открытого ключа KEM передайте ['encapsulateKey'] (или ['encapsulateBits']).
  • 'raw-seed' – байты начального значения закрытого ключа (32 байт для 'MLKEM768-X25519'; 64 байт для 'ML-KEM-768' и 'ML-KEM-1024'). Передайте ['decapsulateKey'] (или ['decapsulateBits']) при импорте закрытого ключа KEM (передача ['encapsulateKey', 'decapsulateKey'] в importKey() или getPublicKey() вызывает SyntaxError). И 'raw', и 'raw-private' вызывают NotSupportedError для закрытых ключей KEM. Обратите внимание, что 'MLKEM768-X25519' поддерживает только 'raw-public', 'raw-seed' и 'jwk', но не 'spki' или 'pkcs8'.
  • 'raw-secret' – байты симметричного секретного ключа ('ChaCha20-Poly1305', принимаются в 'AES-GCM' и 'HKDF').

Экспортируйте открытый ключ получателя с помощью 'raw-public', чтобы байты открытого ключа можно было сериализовать (например, как Base64 через new Uint8Array(recipientPublicKeyBytes).toBase64() или шестнадцатеричный код) и передать отправителям:

// Export the recipient's public key bytes
const recipientPublicKeyBytes = await crypto.subtle.exportKey(
  'raw-public',
  recipientKeyPair.publicKey,
);

// Import the recipient's public key on the sender (usage must be ['encapsulateKey'])
const importedPublicKey = await crypto.subtle.importKey(
  'raw-public',
  recipientPublicKeyBytes,
  'MLKEM768-X25519',
  true,
  ['encapsulateKey'],
);

Если у вас есть только закрытый ключ CryptoKey (даже с extractable: false) и вам нужен соответствующий открытый ключ CryptoKey, вызовите crypto.subtle.getPublicKey():

const publicKey = await crypto.subtle.getPublicKey(
  recipientKeyPair.privateKey,
  ['encapsulateKey'],
);

3. Инкапсуляция и декапсуляция общего ключа

encapsulateKey() и decapsulateKey() импортируют 32-байтовый (256-битный) общий секретный ключ KEM в CryptoKey с помощью алгоритма, указанного в sharedKeyAlgorithm (в формате 'raw-secret'). Передайте 'ChaCha20-Poly1305' или 'AES-GCM' напрямую, чтобы создать ключ шифрования AEAD:

// Sender encapsulates a 256-bit AEAD key and produces a ciphertext capsule
const { sharedKey: senderKey, ciphertext: encapsulatedCiphertext } =
  await crypto.subtle.encapsulateKey(
    'MLKEM768-X25519',
    importedPublicKey,
    'ChaCha20-Poly1305', // Or 'AES-GCM'
    false,
    ['encrypt', 'decrypt'],
  );

// Recipient decapsulates the ciphertext capsule to recover the identical AEAD key
const recipientKey = await crypto.subtle.decapsulateKey(
  'MLKEM768-X25519',
  recipientKeyPair.privateKey,
  encapsulatedCiphertext,
  'ChaCha20-Poly1305',
  false,
  ['encrypt', 'decrypt'],
);

Разделение доменов с помощью 'HKDF'

Чтобы привязать общий ключ к определенному контексту протокола (info и salt) или получить несколько подключей из одной инкапсуляции, передайте 'HKDF' как sharedKeyAlgorithm с использованием ['deriveKey'], а затем вызовите crypto.subtle.deriveKey():

const { sharedKey: hkdfBaseKey, ciphertext } = await crypto.subtle.encapsulateKey(
  'MLKEM768-X25519',
  importedPublicKey,
  'HKDF',
  false,
  ['deriveKey'],
);

const aeadKey = await crypto.subtle.deriveKey(
  {
    name: 'HKDF',
    hash: 'SHA-256',
    salt: new Uint8Array(32),
    info: new TextEncoder().encode('envelope-v1'),
  },
  hkdfBaseKey,
  { name: 'AES-GCM', length: 256 },
  false,
  ['encrypt', 'decrypt'],
);

4. Шифрование и расшифровка полезной нагрузки ('ChaCha20-Poly1305' и 'AES-GCM')

Зашифруйте полезную нагрузку с помощью алгоритма AEAD ('ChaCha20-Poly1305' или 'AES-GCM'), используя новый криптографически случайный 12-байтовый (96-битный) вектор инициализации (iv) для каждой операции шифрования. Никогда не используйте пару (iv, key) повторно. Выберите 'ChaCha20-Poly1305', чтобы обеспечить стабильную производительность на устройствах без аппаратного ускорения AES, или 'AES-GCM', если вы используете WebCrypto или FIPS 140.

  • 'ChaCha20-Poly1305': использует фиксированный 256-битный (32-байтный) ключ (generateKey('ChaCha20-Poly1305', extractable, ['encrypt', 'decrypt']) принимает строку или { name: 'ChaCha20-Poly1305' } без свойства length). iv должен быть ровно 12 байт, а 128-битный тег аутентификации Poly1305 добавляется к зашифрованному тексту.
  • 'AES-GCM': передайте { name: 'AES-GCM', length: 256 } при вызове generateKey() или deriveKey() и 12-байтовый iv при вызове encrypt() и decrypt().
  • additionalData (AAD). Привяжите незашифрованные метаданные (например, идентификатор записи, идентификатор получателя или версию протокола) к тегу аутентификации с помощью additionalData, чтобы при вмешательстве в зашифрованный текст или метаданные decrypt() отклонял запрос с ошибкой OperationError.
/**
 * Encrypts UTF-8 text with an AEAD CryptoKey ('ChaCha20-Poly1305' or 'AES-GCM').
 * @param {CryptoKey} key
 * @param {string} plaintext
 * @param {Uint8Array} [additionalData]
 */
export async function encryptMessage(key, plaintext, additionalData = new Uint8Array(0)) {
  // Always generate a fresh 12-byte (96-bit) IV per message
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const encoded = new TextEncoder().encode(plaintext);

  const ciphertextBuffer = await crypto.subtle.encrypt(
    {
      name: key.algorithm.name,
      iv,
      additionalData,
    },
    key,
    encoded,
  );

  return { iv, ciphertext: new Uint8Array(ciphertextBuffer) };
}

/**
 * Decrypts and authenticates an AEAD payload. Throws OperationError if tampered.
 * @param {CryptoKey} key
 * @param {Uint8Array} iv
 * @param {Uint8Array} ciphertext
 * @param {Uint8Array} [additionalData]
 */
export async function decryptMessage(key, iv, ciphertext, additionalData = new Uint8Array(0)) {
  const plaintextBuffer = await crypto.subtle.decrypt(
    {
      name: key.algorithm.name,
      iv,
      additionalData,
    },
    key,
    ciphertext,
  );

  return new TextDecoder().decode(plaintextBuffer);
}

Резервные стратегии

Если целевая версия Baseline пока не поддерживает SubtleCrypto.supports(), encapsulateKey() или 'ChaCha20-Poly1305', определите поддержку с помощью статического метода SubtleCrypto.supports() и используйте протокол Диффи – Хеллмана с эфемерными и статическими ключами ('X25519' + 'HKDF' + 'AES-GCM' или 'ECDH' с 'P-256', если целевые браузеры не поддерживают 'X25519'):

  • Вызовите SubtleCrypto.supports() в конструкторе SubtleCrypto, а не в crypto.subtle. globalThis.SubtleCrypto?.supports?.('encapsulateKey', 'MLKEM768-X25519', 'ChaCha20-Poly1305') – это синхронный статический метод, возвращающий логическое значение (crypto.subtle.supports – это undefined). Обратите внимание, что SubtleCrypto.supports() проверяет параметры, относящиеся к операции: строки алгоритма работают для 'encapsulateKey', 'decapsulateKey', 'generateKey', 'importKey', 'exportKey' и 'getPublicKey', тогда как для проверки 'encrypt' или 'decrypt' напрямую требуется iv (например, SubtleCrypto.supports('encrypt', { name: 'ChaCha20-Poly1305', iv: new Uint8Array(12) })).
  • Формат ключа в резервном пути ('raw'). Браузеры, поддерживающие Modern WebCrypto, также принимают 'raw-public' в 'X25519' и 'ECDH', но в резервных путях для более старых браузеров используйте 'raw'.
  • Классический резервный вариант ('X25519' + 'HKDF' + 'AES-GCM'). Если SubtleCrypto.supports() недоступен или возвращает false, создайте временную пару ключей 'X25519' для отправителя, экспортируйте временный открытый ключ в виде байтов капсулы, получите 256 бит с помощью deriveBits({ name: 'X25519', public: peerPublicKey }, privateKey, 256) и передайте эти биты через 'HKDF' ('SHA-256'), чтобы получить 256-битный ключ 'AES-GCM'.
const KEM_PUBLIC_KEY_ALGORITHMS = {
  1184: 'ML-KEM-768',
  1216: 'MLKEM768-X25519',
  1568: 'ML-KEM-1024',
};

export function supportsPostQuantumEnvelope() {
  return (
    typeof globalThis.SubtleCrypto?.supports === 'function' &&
    SubtleCrypto.supports('encapsulateKey', 'MLKEM768-X25519', 'ChaCha20-Poly1305')
  );
}

export async function generateRecipientKeys() {
  if (supportsPostQuantumEnvelope()) {
    const keyPair = await crypto.subtle.generateKey(
      'MLKEM768-X25519',
      false,
      ['encapsulateKey', 'decapsulateKey'],
    );
    const publicKeyBytes = new Uint8Array(
      await crypto.subtle.exportKey('raw-public', keyPair.publicKey),
    );
    return { suite: 'MLKEM768-X25519+ChaCha20-Poly1305', keyPair, publicKeyBytes };
  }

  // Classical fallback: X25519 key agreement + HKDF + AES-GCM
  const keyPair = await crypto.subtle.generateKey('X25519', false, ['deriveBits']);
  const publicKeyBytes = new Uint8Array(
    await crypto.subtle.exportKey('raw', keyPair.publicKey),
  );
  return { suite: 'X25519+HKDF+AES-GCM', keyPair, publicKeyBytes };
}

async function deriveFallbackAesKey(privateKey, peerPublicKey, infoBytes = new Uint8Array(0)) {
  const sharedSecret = await crypto.subtle.deriveBits(
    { name: 'X25519', public: peerPublicKey },
    privateKey,
    256,
  );
  const hkdfKey = await crypto.subtle.importKey('raw', sharedSecret, 'HKDF', false, ['deriveKey']);
  return crypto.subtle.deriveKey(
    {
      name: 'HKDF',
      hash: 'SHA-256',
      salt: new Uint8Array(32),
      info: infoBytes,
    },
    hkdfKey,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt'],
  );
}

export async function encapsulateEnvelopeKey(
  recipientPublicKeyOrBytes,
  infoBytes = new Uint8Array(0),
) {
  const pubKey =
    recipientPublicKeyOrBytes instanceof CryptoKey
      ? recipientPublicKeyOrBytes
      : await (() => {
          const bytes =
            recipientPublicKeyOrBytes instanceof Uint8Array
              ? recipientPublicKeyOrBytes
              : new Uint8Array(recipientPublicKeyOrBytes);
          const kemName = KEM_PUBLIC_KEY_ALGORITHMS[bytes.byteLength];
          return kemName
            ? crypto.subtle.importKey('raw-public', bytes, kemName, true, ['encapsulateKey'])
            : crypto.subtle.importKey('raw', bytes, 'X25519', true, []);
        })();

  if (pubKey.algorithm.name !== 'X25519') {
    const { sharedKey, ciphertext } = await crypto.subtle.encapsulateKey(
      pubKey.algorithm.name,
      pubKey,
      'ChaCha20-Poly1305',
      false,
      ['encrypt', 'decrypt'],
    );
    return { sharedKey, capsuleBytes: new Uint8Array(ciphertext) };
  }

  // Classical fallback: generate ephemeral X25519 key pair and send ephemeral public key as capsule
  const ephemeral = await crypto.subtle.generateKey('X25519', false, ['deriveBits']);
  const capsuleBytes = new Uint8Array(await crypto.subtle.exportKey('raw', ephemeral.publicKey));
  const sharedKey = await deriveFallbackAesKey(ephemeral.privateKey, pubKey, infoBytes);
  return { sharedKey, capsuleBytes };
}

export async function decapsulateEnvelopeKey(
  recipientPrivateKey,
  capsuleBytes,
  infoBytes = new Uint8Array(0),
) {
  if (recipientPrivateKey.algorithm.name !== 'X25519') {
    return crypto.subtle.decapsulateKey(
      recipientPrivateKey.algorithm.name,
      recipientPrivateKey,
      capsuleBytes,
      'ChaCha20-Poly1305',
      false,
      ['encrypt', 'decrypt'],
    );
  }

  const ephemeralPubKey = await crypto.subtle.importKey('raw', capsuleBytes, 'X25519', true, []);
  return deriveFallbackAesKey(recipientPrivateKey, ephemeralPubKey, infoBytes);
}