使用 WebCrypto 加密用戶端資料

網頁功能: Web Cryptography

使用 Web Cryptography API (crypto.subtle),透過抗量子金鑰封裝機制 (KEM) 或傳統金鑰協議,在不受信任的網路上建立共用密鑰,並使用支援關聯資料的驗證式加密 (AEAD) 加密瀏覽器中的機密資料。如要簽署及驗證訊息或構件,但不加密,請參閱「使用 WebCrypto 簽署及驗證資料」。

端對端信封式加密流程分為兩個階段:

  • 一次性接收端設定 (步驟 1-2):接收端會產生金鑰組,將 CryptoKeyPair 保留在 IndexedDB 中,供日後工作階段使用,並匯出公開金鑰 ('raw-public') 與傳送端共用。
  • 個別訊息加密 (步驟 3 至 4):針對每個酬載,傳送者會匯入接收者的公開金鑰、呼叫 encapsulateKey() 來產生新的單次共用金鑰和密文封裝、使用新的 12 位元組 IV 加密酬載,然後將 { capsule, iv, ciphertext } 傳送給接收者。

1. 選擇 KEM 演算法並產生收件者金鑰組

Diffie-Hellman 金鑰協議 (deriveKey()) 會結合雙方的金鑰,而金鑰封裝機制 (KEM) 則使用接收者的公開金鑰,在單一 encapsulateKey() 呼叫中產生隨機共用密鑰和密文封裝。接收者使用 decapsulateKey() 從密文封裝中復原相同的共用金鑰。

預設使用 'MLKEM768-X25519':這項演算法結合後量子 ML-KEM (NIST FIPS 203 標準) 和傳統 'X25519',只要其中一種演算法有效,共用密鑰就能保持安全。如果政策要求純 FIPS 203 輸出內容,不含混合元件,請選擇獨立 'ML-KEM-768';如果政策要求符合 NIST 第 5 類 (CNSA 2.0) 規範,請選擇 'ML-KEM-1024'。

演算法名稱 安全等級和用途 公開金鑰 ('raw-public') 私人種子 ('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)。
  • 產生 KEM 金鑰組時,請傳遞 extractable: false 和 ['encapsulateKey', 'decapsulateKey'] (或 ['encapsulateBits', 'decapsulateBits'])。不可擷取的 CryptoKey 和 CryptoKeyPair 物件可透過結構化複製 (objectStore.put(recipientKeyPair, 'recipient-kem-key')),直接在 IndexedDB 中跨工作階段保留,完全不需匯出私密金鑰位元組:
const recipientKeyPair = await crypto.subtle.generateKey(
  'MLKEM768-X25519',
  false,
  ['encapsulateKey', 'decapsulateKey'],
);

2. 匯出及匯入金鑰

現代 WebCrypto 演算法 ('MLKEM768-X25519'、'ML-KEM-768'、'ML-KEM-1024' 和 'ChaCha20-Poly1305') 會拒絕 importKey() 和 exportKey() 中的舊版 'raw' 格式,並傳回 NotSupportedError。請一律指定明確的金鑰格式:

  • 'raw-public':公開金鑰位元組 (1216、1184 或 1568 位元組)。匯入 KEM 公開金鑰時,請傳遞 ['encapsulateKey'] (或 ['encapsulateBits'])。
  • 'raw-seed':私密金鑰種子位元組 ('MLKEM768-X25519' 為 32 位元組;'ML-KEM-768' 和 'ML-KEM-1024' 為 64 位元組)。匯入 KEM 私密金鑰時,請傳遞 ['decapsulateKey'] (或 ['decapsulateBits']) (將 ['encapsulateKey', 'decapsulateKey'] 傳遞至 importKey() 或 getPublicKey() 會擲回 SyntaxError)。'raw' 和 'raw-private' 都會在 KEM 私密金鑰上擲回 NotSupportedError。請注意,'MLKEM768-X25519' 僅支援 'raw-public'、'raw-seed' 和 'jwk',不支援 'spki' 或 'pkcs8'。
  • 'raw-secret':對稱密鑰位元組 ('ChaCha20-Poly1305','AES-GCM' 和 'HKDF' 接受)。

使用 'raw-public' 匯出接收者的公開金鑰,以便將公開金鑰位元組序列化 (例如透過 new Uint8Array(recipientPublicKeyBytes).toBase64() 或十六進位格式轉換為 Base64),並與傳送者共用:

// 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() 使用 sharedKeyAlgorithm 中指定的演算法 (採用 'raw-secret' 格式),將 32 位元組 (256 位元) KEM 共用密鑰匯入 CryptoKey。直接傳遞 '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) 組合。如果裝置沒有專屬 AES 硬體加速功能,請選擇 'ChaCha20-Poly1305',確保裝置效能穩定一致。如要與現有 WebCrypto 或 FIPS 140 部署項目互通,請選擇 'AES-GCM'。

  • 'ChaCha20-Poly1305':使用固定的 256 位元 (32 位元組) 金鑰 (generateKey('ChaCha20-Poly1305', extractable, ['encrypt', 'decrypt']) 接受字串或不含length 屬性的 { name: 'ChaCha20-Poly1305' })。iv 必須剛好為 12 個位元組,且 128 位元的 Poly1305 驗證標記會附加至密文。
  • 'AES-GCM':呼叫 generateKey() 或 deriveKey() 時傳遞 { name: 'AES-GCM', length: 256 },呼叫 encrypt() 和 decrypt() 時則傳遞 12 位元組的 iv。
  • additionalData (AAD):透過 additionalData 將未加密的中繼資料 (例如記錄 ID、收件者 ID 或通訊協定版本) 繫結至驗證標記,這樣一來,如果密文或中繼資料遭到竄改,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);
}

備用策略

如果基準目標尚未支援 SubtleCrypto.supports()、encapsulateKey() 或 'ChaCha20-Poly1305',請使用 static SubtleCrypto.supports() 方法偵測支援,並改用暫時靜態 Diffie-Hellman 金鑰協議 ('X25519' + 'HKDF' + 'AES-GCM',或在指定不支援 'X25519' 的瀏覽器時使用 'ECDH' 和 'P-256'):

  • 在 SubtleCrypto 建構函式上呼叫 SubtleCrypto.supports(),而不是在 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 的瀏覽器也接受 'X25519' 和 'ECDH' 上的 'raw-public',但針對舊版瀏覽器的備用路徑,請使用 'raw'。
  • 傳統備用機制 ('X25519' + 'HKDF' + 'AES-GCM'):如果 SubtleCrypto.supports() 無法使用或傳回 false,請為傳送者產生臨時 'X25519' 金鑰組、將臨時公開金鑰匯出為封裝位元組、透過 deriveBits({ name: 'X25519', public: peerPublicKey }, privateKey, 256) 衍生 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);
}