เข้ารหัสข้อมูลฝั่งไคลเอ็นต์ด้วย WebCrypto

ฟีเจอร์บนเว็บ: Web Cryptography

ใช้ Web Cryptography API (crypto.subtle) เพื่อสร้างข้อมูลลับที่ใช้ร่วมกันผ่านเครือข่ายที่ไม่น่าเชื่อถือโดยใช้กลไกการห่อหุ้มคีย์ (KEM) ที่ต้านทานควอนตัมหรือข้อตกลงคีย์แบบคลาสสิก และเข้ารหัสข้อมูลที่ละเอียดอ่อนในเบราว์เซอร์ด้วยการเข้ารหัสที่ตรวจสอบสิทธิ์แล้วพร้อมข้อมูลที่เกี่ยวข้อง (AEAD) หากต้องการลงชื่อและยืนยันข้อความหรืออาร์ติแฟกต์โดยไม่เข้ารหัส โปรดดูหัวข้อลงชื่อและยืนยันข้อมูลด้วย WebCrypto

ขั้นตอนการเข้ารหัส Envelope แบบครบวงจรแบ่งออกเป็น 2 ระยะ ดังนี้

  • การตั้งค่าผู้รับแบบครั้งเดียว (ขั้นตอนที่ 1-2): ผู้รับจะสร้างคู่คีย์ โดยเก็บ CryptoKeyPair ไว้ใน IndexedDB สำหรับเซสชันในอนาคต และส่งออกคีย์สาธารณะ ('raw-public') เพื่อแชร์กับผู้ส่ง
  • การเข้ารหัสต่อข้อความ (ขั้นตอนที่ 3-4): สำหรับเพย์โหลดแต่ละรายการ ผู้ส่งจะนำเข้าคีย์สาธารณะของผู้รับ เรียกใช้ encapsulateKey() เพื่อสร้างคีย์ที่ใช้ร่วมกันแบบใช้ครั้งเดียวใหม่และแคปซูลข้อความเข้ารหัส เข้ารหัสเพย์โหลดด้วย IV ขนาด 12 ไบต์ใหม่ และส่ง { capsule, iv, ciphertext } ไปยังผู้รับ

1. เลือกอัลกอริทึม KEM และสร้างคู่คีย์ผู้รับ

กลไกการห่อหุ้มคีย์ (KEM) แตกต่างจากข้อตกลงคีย์ Diffie-Hellman (deriveKey()) ที่ทั้ง 2 ฝ่ายรวมคีย์เข้าด้วยกัน โดยจะใช้คีย์สาธารณะของผู้รับเพื่อสร้างทั้งความลับที่ใช้ร่วมกันแบบสุ่มและแคปซูลข้อความเข้ารหัสในการเรียก encapsulateKey() ครั้งเดียว ผู้รับจะกู้คืนคีย์ที่แชร์เดียวกันจากแคปซูลข้อความเข้ารหัสโดยใช้ decapsulateKey()

ใช้ 'MLKEM768-X25519' โดยค่าเริ่มต้น: ซึ่งจะรวม ML-KEM หลังยุคควอนตัม (ได้มาตรฐาน NIST FIPS 203) เข้ากับ 'X25519' แบบคลาสสิก เพื่อให้ข้อมูลลับที่แชร์ยังคงปลอดภัยตราบใดที่อัลกอริทึมใดอัลกอริทึมหนึ่งยังคงทำงานอยู่ เลือกสแตนด์อโลน 'ML-KEM-768' เมื่อข้อกำหนดด้านนโยบายกำหนดให้ใช้เอาต์พุต FIPS 203 แบบล้วนๆ โดยไม่มีคอมโพเนนต์แบบผสม หรือเลือก 'ML-KEM-1024' เมื่อข้อกำหนดด้านนโยบายกำหนดให้เป็นไปตามข้อกำหนด NIST หมวดหมู่ 5 (CNSA 2.0)

ชื่ออัลกอริทึม ระดับการรักษาความปลอดภัยและกรณีการใช้งาน คีย์สาธารณะ ('raw-public') Seed ส่วนตัว ('raw-seed') ขนาดข้อความเข้ารหัส
'MLKEM768-X25519' แบบผสมระหว่างหลังควอนตัม ('ML-KEM-768') + แบบคลาสสิก ('X25519') ค่าเริ่มต้นที่แนะนำ 1216 ไบต์ 32 ไบต์ 1120 ไบต์
'ML-KEM-768' NIST หมวดหมู่ 3 (ความปลอดภัย ~192 บิต); KEM หลังยุคควอนตัมแบบสแตนด์อโลน 1184 ไบต์ 64 ไบต์ 1088 ไบต์
'ML-KEM-1024' NIST หมวดหมู่ 5 (การรักษาความปลอดภัย ~256 บิต) ขอบเขตการรักษาความปลอดภัยสูงสุด 1568 ไบต์ 64 ไบต์ 1568 ไบต์
  • ส่ง 'MLKEM768-X25519', 'ML-KEM-768' หรือ 'ML-KEM-1024' เป็นชื่ออัลกอริทึม (ชื่อแทน เช่น 'X-Wing' หรือ 'X25519MLKEM768' รวมถึง 'ML-KEM-512' จะแสดง NotSupportedError)
  • ส่ง extractable: false และ ['encapsulateKey', 'decapsulateKey'] (หรือ ['encapsulateBits', 'decapsulateBits']) เมื่อสร้างคู่คีย์ KEM ออบเจ็กต์ 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 ไบต์) ส่ง ['encapsulateKey'] (หรือ ['encapsulateBits']) เมื่อนำเข้าคีย์สาธารณะ KEM
  • 'raw-seed': ไบต์ 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. Encapsulate และ Decapsulate คีย์ที่แชร์

encapsulateKey() และ decapsulateKey() นำเข้าข้อมูลลับที่ใช้ร่วมกันของ KEM ขนาด 32 ไบต์ (256 บิต) ไปยัง 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') โดยใช้เวกเตอร์การเริ่มต้น (iv) ขนาด 12 ไบต์ (96 บิต) ที่สุ่มขึ้นมาใหม่โดยใช้การเข้ารหัสสำหรับการเข้ารหัสทุกครั้ง อย่าใช้คู่ (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 ไบต์พอดี และแท็กการตรวจสอบสิทธิ์ Poly1305 ขนาด 128 บิตจะต่อท้ายข้อความเข้ารหัส
  • 'AES-GCM': ส่ง { name: 'AES-GCM', length: 256 } เมื่อเรียกใช้ generateKey() หรือ deriveKey() และ iv ขนาด 12 ไบต์เมื่อเรียกใช้ 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' ให้ตรวจหาการรองรับโดยใช้เมธอด static SubtleCrypto.supports() และเปลี่ยนไปใช้ข้อตกลงคีย์ Diffie-Hellman แบบชั่วคราวและคงที่ ('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'): แม้ว่าเบราว์เซอร์ที่รองรับ WebCrypto สมัยใหม่จะยอมรับ 'raw-public' ใน 'X25519' และ 'ECDH' ด้วย แต่ให้ใช้ 'raw' ในเส้นทางสำรองที่กำหนดเป้าหมายเป็นเบราว์เซอร์รุ่นเก่า
  • Classical fallback ('X25519' + 'HKDF' + 'AES-GCM'): เมื่อ SubtleCrypto.supports() ไม่พร้อมใช้งานหรือแสดงผลเป็น false ให้สร้างคู่คีย์ 'X25519' แบบชั่วคราวสำหรับผู้ส่ง ส่งออกคีย์สาธารณะแบบชั่วคราวเป็นไบต์แคปซูล รับ 256 บิตผ่าน deriveBits({ name: 'X25519', public: peerPublicKey }, privateKey, 256) และส่งบิตเหล่านั้นผ่าน 'HKDF' ('SHA-256') เพื่อรับคีย์ 'AES-GCM' ขนาด 256 บิต
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);
}