Mengenkripsi data sisi klien dengan WebCrypto

Fitur web: Web Cryptography

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 CryptoKeyPair di 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', menampilkan NotSupportedError).
  • Teruskan extractable: false dan ['encapsulateKey', 'decapsulateKey'] (atau ['encapsulateBits', 'decapsulateBits']) saat membuat pasangan kunci KEM. Objek CryptoKey dan CryptoKeyPair yang 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 (byte 1216, 1184, atau 1568). Teruskan ['encapsulateKey'] (atau ['encapsulateBits']) saat mengimpor kunci publik KEM.
  • 'raw-seed': Byte seed kunci pribadi (32 byte untuk 'MLKEM768-X25519'; 64 byte untuk 'ML-KEM-768' dan 'ML-KEM-1024'). Teruskan ['decapsulateKey'] (atau ['decapsulateBits']) saat mengimpor kunci pribadi KEM (meneruskan ['encapsulateKey', 'decapsulateKey'] ke importKey() atau getPublicKey() akan menampilkan SyntaxError). 'raw' dan 'raw-private' menampilkan NotSupportedError pada 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 properti length). iv harus tepat 12 byte, dan tag autentikasi Poly1305 128-bit ditambahkan ke ciphertext.
  • 'AES-GCM': Teruskan { name: 'AES-GCM', length: 256 } saat memanggil generateKey() atau deriveKey(), dan iv 12 byte saat memanggil encrypt() dan decrypt().
  • additionalData (AAD): Mengikat metadata yang tidak dienkripsi (seperti ID data, ID penerima, atau versi protokol) ke tag autentikasi melalui additionalData sehingga pengubahan teks tersandi atau metadata akan menyebabkan decrypt() menolak dengan 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);
}

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 konstruktor SubtleCrypto, bukan pada crypto.subtle: globalThis.SubtleCrypto?.supports?.('encapsulateKey', 'MLKEM768-X25519', 'ChaCha20-Poly1305') adalah metode statis sinkron yang menampilkan boolean (crypto.subtle.supports adalah undefined). Perhatikan bahwa SubtleCrypto.supports() memvalidasi parameter khusus operasi: string algoritma dasar berfungsi untuk 'encapsulateKey', 'decapsulateKey', 'generateKey', 'importKey', 'exportKey', dan 'getPublicKey', sedangkan pemeriksaan 'encrypt' atau 'decrypt' secara langsung memerlukan iv (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'): Jika SubtleCrypto.supports() tidak tersedia atau menampilkan false, buat pasangan kunci 'X25519' sementara untuk pengirim, ekspor kunci publik sementara sebagai byte kapsul, dapatkan 256 bit melalui deriveBits({ 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);
}