Gunakan Web Cryptography API (crypto.subtle) untuk membuat rahasia bersama melalui jaringan yang tidak tepercaya menggunakan Mekanisme Enkapsulasi Kunci (KEM) yang tahan kuantum atau perjanjian kunci klasik, dan mengenkripsi data sensitif di browser dengan Authenticated Encryption with Associated Data (AEAD). Untuk menandatangani dan memverifikasi pesan atau artefak tanpa mengenkripsinya, lihat Menandatangani dan memverifikasi data dengan WebCrypto.
Alur enkripsi amplop menyeluruh dibagi menjadi dua fase:
- Penyiapan penerima satu kali (Langkah 1–2): Penerima membuat pasangan kunci, mempertahankan
CryptoKeyPairdi IndexedDB untuk sesi mendatang, dan mengekspor kunci publik ('raw-public') untuk dibagikan kepada pengirim. - Enkripsi per pesan (Langkah 3–4): Untuk setiap payload, pengirim mengimpor kunci publik penerima, memanggil
encapsulateKey()untuk membuat kunci bersama satu kali dan kapsul ciphertext baru, mengenkripsi payload dengan IV 12 byte baru, dan mengirimkan{ capsule, iv, ciphertext }ke penerima.
1. Memilih algoritma KEM dan membuat pasangan kunci penerima
Tidak seperti perjanjian kunci Diffie-Hellman (deriveKey()), yang mengharuskan kedua pihak menggabungkan kunci mereka, Mekanisme Enkapsulasi Kunci (KEM) menggunakan kunci publik penerima untuk menghasilkan rahasia bersama acak dan kapsul ciphertext dalam satu panggilan encapsulateKey(). Penerima memulihkan kunci bersama yang sama dari kapsul ciphertext menggunakan decapsulateKey().
Gunakan 'MLKEM768-X25519' secara default: algoritma ini menggabungkan ML-KEM pasca-kuantum (distandarkan dalam NIST FIPS 203) dengan 'X25519' klasik sehingga rahasia bersama tetap aman selama salah satu algoritma berlaku. Pilih 'ML-KEM-768' mandiri jika kebijakan memerlukan output FIPS 203 murni tanpa komponen hybrid, atau 'ML-KEM-1024' jika kebijakan memerlukan kepatuhan NIST Kategori 5 (CNSA 2.0).
| Nama algoritma | Tingkat keamanan & kasus penggunaan | Kunci publik ('raw-public') |
Seed pribadi ('raw-seed') |
Ukuran ciphertext |
|---|---|---|---|---|
'MLKEM768-X25519' |
Hybrid pasca-kuantum ('ML-KEM-768') + klasik ('X25519'); default yang direkomendasikan |
1216 byte | 32 byte | 1120 byte |
'ML-KEM-768' |
NIST Kategori 3 (keamanan ~192-bit); KEM pasca-kuantum mandiri | 1184 byte | 64 byte | 1088 byte |
'ML-KEM-1024' |
NIST Kategori 5 (keamanan ~256-bit); margin keamanan tertinggi | 1.568 byte | 64 byte | 1.568 byte |
- Teruskan
'MLKEM768-X25519','ML-KEM-768', atau'ML-KEM-1024'sebagai nama algoritma (alias seperti'X-Wing'atau'X25519MLKEM768', serta'ML-KEM-512', menampilkanNotSupportedError). - Teruskan
extractable: falsedan['encapsulateKey', 'decapsulateKey'](atau['encapsulateBits', 'decapsulateBits']) saat membuat pasangan kunci KEM. ObjekCryptoKeydanCryptoKeyPairyang tidak dapat diekstrak dapat dipertahankan di seluruh sesi secara langsung di IndexedDB melalui cloning terstruktur (objectStore.put(recipientKeyPair, 'recipient-kem-key')) tanpa pernah mengekspor byte kunci pribadi:
const recipientKeyPair = await crypto.subtle.generateKey(
'MLKEM768-X25519',
false,
['encapsulateKey', 'decapsulateKey'],
);
2. Mengekspor dan mengimpor kunci
Algoritma WebCrypto modern ('MLKEM768-X25519', 'ML-KEM-768', 'ML-KEM-1024', dan 'ChaCha20-Poly1305') menolak format 'raw' lama di importKey() dan exportKey() dengan NotSupportedError. Selalu tentukan format kunci eksplisit:
'raw-public': Byte kunci publik (byte1216,1184, atau1568). Teruskan['encapsulateKey'](atau['encapsulateBits']) saat mengimpor kunci publik KEM.'raw-seed': Byte seed kunci pribadi (32byte untuk'MLKEM768-X25519';64byte untuk'ML-KEM-768'dan'ML-KEM-1024'). Teruskan['decapsulateKey'](atau['decapsulateBits']) saat mengimpor kunci pribadi KEM (meneruskan['encapsulateKey', 'decapsulateKey']keimportKey()ataugetPublicKey()akan menampilkanSyntaxError).'raw'dan'raw-private'menampilkanNotSupportedErrorpada kunci pribadi KEM. Perhatikan bahwa'MLKEM768-X25519'hanya mendukung'raw-public','raw-seed', dan'jwk'—tidak mendukung'spki'atau'pkcs8'.'raw-secret': Byte kunci rahasia simetris ('ChaCha20-Poly1305', dan diterima di'AES-GCM'dan'HKDF').
Ekspor kunci publik penerima dengan 'raw-public' agar byte kunci publik dapat diserialisasi (misalnya, sebagai Base64 melalui new Uint8Array(recipientPublicKeyBytes).toBase64() atau hex) dan dibagikan kepada pengirim:
// 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'],
);
Jika Anda hanya memiliki CryptoKey pribadi (bahkan dengan extractable: false) dan memerlukan CryptoKey publik yang sesuai, panggil crypto.subtle.getPublicKey():
const publicKey = await crypto.subtle.getPublicKey(
recipientKeyPair.privateKey,
['encapsulateKey'],
);
3. Mengenkapsulasi dan mendekapsulasi kunci bersama
encapsulateKey() dan decapsulateKey() mengimpor rahasia bersama KEM 32 byte (256 bit) ke dalam CryptoKey menggunakan algoritma yang ditentukan dalam sharedKeyAlgorithm (dengan format 'raw-secret'). Teruskan 'ChaCha20-Poly1305' atau 'AES-GCM' secara langsung untuk menghasilkan kunci enkripsi 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'],
);
Pemisahan domain dengan 'HKDF'
Untuk mengikat kunci bersama ke konteks protokol tertentu (info dan salt) atau mendapatkan beberapa subkunci dari satu enkapsulasi, teruskan 'HKDF' sebagai sharedKeyAlgorithm dengan penggunaan ['deriveKey'], lalu panggil 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. Mengenkripsi dan mendekripsi payload ('ChaCha20-Poly1305' dan 'AES-GCM')
Enkripsi data payload dengan algoritma AEAD ('ChaCha20-Poly1305' atau 'AES-GCM') menggunakan vektor inisialisasi (iv) 12 byte (96 bit) yang baru dan acak secara kriptografis untuk setiap operasi enkripsi. Jangan pernah menggunakan kembali pasangan (iv, key). Pilih 'ChaCha20-Poly1305' untuk performa waktu konstan yang konsisten di seluruh perangkat tanpa akselerasi hardware AES khusus, atau 'AES-GCM' saat beroperasi dengan deployment WebCrypto atau FIPS 140 yang ada.
'ChaCha20-Poly1305': Menggunakan kunci 256-bit (32-byte) tetap (generateKey('ChaCha20-Poly1305', extractable, ['encrypt', 'decrypt'])menggunakan string atau{ name: 'ChaCha20-Poly1305' }tanpa propertilength).ivharus tepat 12 byte, dan tag autentikasi Poly1305 128-bit ditambahkan ke ciphertext.'AES-GCM': Teruskan{ name: 'AES-GCM', length: 256 }saat memanggilgenerateKey()atauderiveKey(), daniv12 byte saat memanggilencrypt()dandecrypt().additionalData(AAD): Mengikat metadata yang tidak dienkripsi (seperti ID data, ID penerima, atau versi protokol) ke tag autentikasi melaluiadditionalDatasehingga pengubahan teks tersandi atau metadata akan menyebabkandecrypt()menolak denganOperationError.
/**
* 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);
}
Strategi penggantian
Jika target Dasar Pengukuran Anda belum mendukung SubtleCrypto.supports(), encapsulateKey(), atau 'ChaCha20-Poly1305', deteksi dukungan menggunakan metode SubtleCrypto.supports() statis dan beralihlah ke perjanjian kunci Diffie-Hellman ephemeral-statis ('X25519' + 'HKDF' + 'AES-GCM', atau 'ECDH' dengan 'P-256' saat menargetkan browser sebelum dukungan 'X25519'):
- Panggil
SubtleCrypto.supports()pada konstruktorSubtleCrypto, bukan padacrypto.subtle:globalThis.SubtleCrypto?.supports?.('encapsulateKey', 'MLKEM768-X25519', 'ChaCha20-Poly1305')adalah metode statis sinkron yang menampilkan boolean (crypto.subtle.supportsadalahundefined). Perhatikan bahwaSubtleCrypto.supports()memvalidasi parameter khusus operasi: string algoritma dasar berfungsi untuk'encapsulateKey','decapsulateKey','generateKey','importKey','exportKey', dan'getPublicKey', sedangkan pemeriksaan'encrypt'atau'decrypt'secara langsung memerlukaniv(misalnya,SubtleCrypto.supports('encrypt', { name: 'ChaCha20-Poly1305', iv: new Uint8Array(12) })). - Format kunci di penggantian (
'raw'): Meskipun browser yang mendukung WebCrypto Modern juga menerima'raw-public'di'X25519'dan'ECDH', gunakan'raw'di jalur penggantian yang menargetkan browser lama. - Penggantian klasik (
'X25519'+'HKDF'+'AES-GCM'): JikaSubtleCrypto.supports()tidak tersedia atau menampilkanfalse, buat pasangan kunci'X25519'sementara untuk pengirim, ekspor kunci publik sementara sebagai byte kapsul, dapatkan 256 bit melaluideriveBits({ name: 'X25519', public: peerPublicKey }, privateKey, 256), dan teruskan bit tersebut melalui'HKDF'('SHA-256') untuk mendapatkan kunci'AES-GCM'256 bit.
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);
}