Mã hoá dữ liệu phía máy khách bằng WebCrypto

Tính năng web: Mật mã học trên web

Sử dụng Web Cryptography API (crypto.subtle) để thiết lập khoá bí mật dùng chung qua các mạng không đáng tin cậy bằng Cơ chế đóng gói khoá (KEM) kháng lượng tử hoặc thoả thuận khoá cổ điển, đồng thời mã hoá dữ liệu nhạy cảm trong trình duyệt bằng phương thức Mã hoá đã xác thực có dữ liệu liên kết (AEAD). Để ký và xác minh thông báo hoặc cấu phần phần mềm mà không mã hoá, hãy xem bài viết Ký và xác minh dữ liệu bằng WebCrypto.

Quy trình mã hoá phong bì hai đầu được chia thành hai giai đoạn:

  • Thiết lập người nhận một lần (Bước 1–2): Người nhận tạo một cặp khoá, lưu giữ CryptoKeyPair trong IndexedDB cho các phiên trong tương lai và xuất khoá công khai ('raw-public') để chia sẻ với người gửi.
  • Mã hoá theo từng thông báo (Bước 3 – 4): Đối với mỗi tải trọng, người gửi sẽ nhập khoá công khai của người nhận, gọi encapsulateKey() để tạo khoá dùng chung một lần mới và viên nang văn bản mật mã, mã hoá tải trọng bằng IV 12 byte mới và gửi { capsule, iv, ciphertext } cho người nhận.

1. Chọn thuật toán KEM và tạo cặp khoá người nhận

Không giống như thoả thuận khoá Diffie-Hellman (deriveKey()), trong đó cả hai bên đều kết hợp khoá của mình, Cơ chế đóng gói khoá (KEM) sử dụng khoá công khai của người nhận để tạo cả khoá bí mật dùng chung ngẫu nhiên và gói văn bản mật mã trong một lệnh gọi encapsulateKey() duy nhất. Người nhận khôi phục cùng một khoá dùng chung từ hộp văn bản mật mã bằng decapsulateKey().

Sử dụng 'MLKEM768-X25519' theo mặc định: thuật toán này kết hợp ML-KEM hậu lượng tử (được chuẩn hoá trong NIST FIPS 203) với 'X25519' cổ điển để khoá bí mật dùng chung luôn được bảo mật miễn là một trong hai thuật toán vẫn hoạt động. Chọn 'ML-KEM-768' độc lập khi yêu cầu theo chính sách yêu cầu đầu ra FIPS 203 thuần tuý mà không có thành phần kết hợp hoặc 'ML-KEM-1024' khi yêu cầu theo chính sách yêu cầu tuân thủ NIST Danh mục 5 (CNSA 2.0).

Tên thuật toán Mức an ninh và trường hợp sử dụng Khoá công khai ('raw-public') Hạt giống riêng tư ('raw-seed') Kích thước văn bản mật mã
'MLKEM768-X25519' Kết hợp hậu lượng tử ('ML-KEM-768') + cổ điển ('X25519'); mặc định được đề xuất 1216 byte 32 byte 1120 byte
'ML-KEM-768' NIST Loại 3 (bảo mật ~192 bit); KEM hậu lượng tử độc lập 1184 byte 64 byte 1088 byte
'ML-KEM-1024' NIST Loại 5 (bảo mật ~256 bit); biên độ bảo mật cao nhất 1568 byte 64 byte 1568 byte
  • Truyền 'MLKEM768-X25519', 'ML-KEM-768' hoặc 'ML-KEM-1024' làm tên thuật toán (các bí danh như 'X-Wing' hoặc 'X25519MLKEM768', cũng như 'ML-KEM-512', sẽ gửi NotSupportedError).
  • Truyền extractable: false và ['encapsulateKey', 'decapsulateKey'] (hoặc ['encapsulateBits', 'decapsulateBits']) khi tạo cặp khoá KEM. Bạn có thể lưu trữ trực tiếp các đối tượng CryptoKey và CryptoKeyPair không thể trích xuất trong các phiên trong IndexedDB thông qua tính năng sao chép có cấu trúc (objectStore.put(recipientKeyPair, 'recipient-kem-key')) mà không cần xuất byte khoá riêng tư:
const recipientKeyPair = await crypto.subtle.generateKey(
  'MLKEM768-X25519',
  false,
  ['encapsulateKey', 'decapsulateKey'],
);

2. Xuất và nhập khoá

Các thuật toán WebCrypto hiện đại ('MLKEM768-X25519', 'ML-KEM-768', 'ML-KEM-1024' và 'ChaCha20-Poly1305') từ chối định dạng 'raw' cũ trong importKey() và exportKey() bằng NotSupportedError. Luôn chỉ định định dạng khoá rõ ràng:

  • 'raw-public': Byte khoá công khai (1216, 1184 hoặc 1568 byte). Truyền ['encapsulateKey'] (hoặc ['encapsulateBits']) khi nhập khoá công khai KEM.
  • 'raw-seed': Byte hạt giống khoá riêng tư (32 byte cho 'MLKEM768-X25519'; 64 byte cho 'ML-KEM-768' và 'ML-KEM-1024'). Truyền ['decapsulateKey'] (hoặc ['decapsulateBits']) khi nhập khoá riêng tư KEM (truyền ['encapsulateKey', 'decapsulateKey'] đến importKey() hoặc getPublicKey() sẽ gửi SyntaxError). Cả 'raw' và 'raw-private' đều gửi NotSupportedError trên khoá riêng tư KEM. Xin lưu ý rằng 'MLKEM768-X25519' chỉ hỗ trợ 'raw-public', 'raw-seed' và 'jwk', không hỗ trợ 'spki' hoặc 'pkcs8'.
  • 'raw-secret': Byte khoá bí mật đối xứng ('ChaCha20-Poly1305' và được chấp nhận trên 'AES-GCM' và 'HKDF').

Xuất khoá công khai của người nhận bằng 'raw-public' để có thể chuyển đổi tuần tự các byte khoá công khai (ví dụ: dưới dạng Base64 thông qua new Uint8Array(recipientPublicKeyBytes).toBase64() hoặc hệ thập lục phân) và chia sẻ với người gửi:

// 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'],
);

Nếu bạn chỉ giữ một CryptoKey riêng tư (ngay cả với extractable: false) và cần CryptoKey công khai tương ứng, hãy gọi crypto.subtle.getPublicKey():

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

3. Đóng gói và giải nén khoá dùng chung

encapsulateKey() và decapsulateKey() nhập khoá bí mật dùng chung KEM 32 byte (256 bit) vào CryptoKey bằng thuật toán được chỉ định trong sharedKeyAlgorithm (với định dạng 'raw-secret'). Truyền trực tiếp 'ChaCha20-Poly1305' hoặc 'AES-GCM' để tạo khoá mã hoá 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'],
);

Phân tách miền bằng 'HKDF'

Để liên kết khoá dùng chung với một bối cảnh giao thức cụ thể (info và salt) hoặc lấy nhiều khoá phụ từ một gói duy nhất, hãy truyền 'HKDF' dưới dạng sharedKeyAlgorithm có mức sử dụng ['deriveKey'], sau đó gọi 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. Mã hoá và giải mã tải trọng ('ChaCha20-Poly1305' và 'AES-GCM')

Mã hoá dữ liệu tải trọng bằng thuật toán AEAD ('ChaCha20-Poly1305' hoặc 'AES-GCM') bằng cách sử dụng vectơ khởi tạo 12 byte (96 bit) (iv) ngẫu nhiên, mới và được mã hoá cho mọi thao tác mã hoá. Không bao giờ sử dụng lại một cặp (iv, key). Chọn 'ChaCha20-Poly1305' để có hiệu suất thời gian không đổi nhất quán trên các thiết bị không có tính năng tăng tốc phần cứng AES chuyên dụng hoặc 'AES-GCM' khi tương tác với các bản triển khai WebCrypto hoặc FIPS 140 hiện có.

  • 'ChaCha20-Poly1305': Sử dụng khoá 256 bit (32 byte) cố định (generateKey('ChaCha20-Poly1305', extractable, ['encrypt', 'decrypt']) lấy một chuỗi hoặc { name: 'ChaCha20-Poly1305' } không có thuộc tính length). iv phải có độ dài chính xác là 12 byte và thẻ xác thực Poly1305 128 bit được thêm vào văn bản mật mã.
  • 'AES-GCM': Truyền { name: 'AES-GCM', length: 256 } khi gọi generateKey() hoặc deriveKey() và iv 12 byte khi gọi encrypt() và decrypt().
  • additionalData (AAD): Liên kết siêu dữ liệu chưa mã hoá (chẳng hạn như mã bản ghi, mã người nhận hoặc phiên bản giao thức) với thẻ xác thực thông qua additionalData để việc can thiệp vào bản mã hoặc siêu dữ liệu khiến decrypt() từ chối bằng 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);
}

Chiến lược dự phòng

Nếu mục tiêu Baseline của bạn chưa hỗ trợ SubtleCrypto.supports(), encapsulateKey() hoặc 'ChaCha20-Poly1305', hãy phát hiện khả năng hỗ trợ bằng phương thức static SubtleCrypto.supports() và quay lại thoả thuận khoá Diffie-Hellman tạm thời-tĩnh ('X25519' + 'HKDF' + 'AES-GCM' hoặc 'ECDH' với 'P-256' khi nhắm đến các trình duyệt trước khi hỗ trợ 'X25519'):

  • Gọi SubtleCrypto.supports() trên hàm khởi tạo SubtleCrypto, chứ không phải trên crypto.subtle: globalThis.SubtleCrypto?.supports?.('encapsulateKey', 'MLKEM768-X25519', 'ChaCha20-Poly1305') là một phương thức tĩnh đồng bộ trả về giá trị boolean (crypto.subtle.supports là undefined). Xin lưu ý rằng SubtleCrypto.supports() xác thực các tham số dành riêng cho thao tác: chuỗi thuật toán thuần tuý hoạt động cho 'encapsulateKey', 'decapsulateKey', 'generateKey', 'importKey', 'exportKey' và 'getPublicKey', trong khi việc thăm dò 'encrypt' hoặc 'decrypt' trực tiếp yêu cầu iv (ví dụ: SubtleCrypto.supports('encrypt', { name: 'ChaCha20-Poly1305', iv: new Uint8Array(12) })).
  • Định dạng khoá trong phương án dự phòng ('raw'): Mặc dù các trình duyệt hỗ trợ WebCrypto hiện đại cũng chấp nhận 'raw-public' trên 'X25519' và 'ECDH', nhưng hãy sử dụng 'raw' trong các đường dẫn dự phòng nhắm đến các trình duyệt cũ.
  • Dự phòng cổ điển ('X25519' + 'HKDF' + 'AES-GCM'): Khi SubtleCrypto.supports() không hoạt động hoặc trả về false, hãy tạo một cặp khoá 'X25519' tạm thời cho người gửi, xuất khoá công khai tạm thời dưới dạng byte đóng gói, lấy 256 bit thông qua deriveBits({ name: 'X25519', public: peerPublicKey }, privateKey, 256) và truyền các bit đó qua 'HKDF' ('SHA-256') để lấy khoá '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);
}