Используйте 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);
}