WebCrypto を使用してクライアントサイドでデータを暗号化する

ウェブ機能: Web Cryptography

Web Cryptography API(crypto.subtle)を使用して、耐量子鍵カプセル化メカニズム(KEM)または従来の鍵合意を使用して、信頼できないネットワーク上で共有シークレットを確立し、認証付き暗号(AEAD)を使用してブラウザで機密データを暗号化します。メッセージやアーティファクトを暗号化せずに署名して検証する方法については、WebCrypto でデータの署名と検証を行うをご覧ください。

エンドツーエンドのエンベロープ暗号化フローは、次の 2 つのフェーズに分かれています。

  • 受信者の 1 回限りの設定(ステップ 1 ~ 2): 受信者は鍵ペアを生成し、今後のセッションのために CryptoKeyPair を IndexedDB に保存します。また、公開鍵('raw-public')をエクスポートして送信者と共有します。
  • メッセージごとの暗号化(ステップ 3 ~ 4): ペイロードごとに、送信者は受信者の公開鍵をインポートし、encapsulateKey() を呼び出して新しい 1 回限りの共有鍵と暗号テキスト カプセルを生成し、新しい 12 バイトの IV でペイロードを暗号化して、{ capsule, iv, ciphertext } を受信者に送信します。

1. KEM アルゴリズムを選択して受信者の鍵ペアを生成する

両者が鍵を組み合わせる Diffie-Hellman 鍵合意(deriveKey())とは異なり、鍵カプセル化メカニズム(KEM)では、受信者の公開鍵を使用して、1 回の 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')。推奨されるデフォルト 1,216 バイト 32 バイト 1,120 バイト
'ML-KEM-768' NIST カテゴリ 3(約 192 ビットのセキュリティ)、スタンドアロンのポスト量子 KEM 1,184 バイト 64 バイト 1,088 バイト
'ML-KEM-1024' NIST カテゴリ 5(約 256 ビットのセキュリティ)、最高レベルのセキュリティ マージン 1,568 バイト 64 バイト 1,568 バイト
  • アルゴリズム名として '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() または 16 進数で 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)にバインドする場合、または 1 つのカプセル化から複数のサブキーを導出する場合は、'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')

暗号化オペレーションごとに、新しい暗号的にランダムな 12 バイト(96 ビット)の初期化ベクトル(iv)を使用して、AEAD アルゴリズム('ChaCha20-Poly1305' または 'AES-GCM')でペイロード データを暗号化します。(iv, key) ペアは再利用しないでください。専用の AES ハードウェア アクセラレーションがないデバイス間で一貫した定数時間パフォーマンスを実現するには 'ChaCha20-Poly1305' を選択し、既存の WebCrypto または FIPS 140 デプロイと相互運用する場合は 'AES-GCM' を選択します。

  • 'ChaCha20-Poly1305': 固定の 256 ビット(32 バイト)鍵を使用します(generateKey('ChaCha20-Poly1305', extractable, ['encrypt', 'decrypt']) は文字列または { name: 'ChaCha20-Poly1305' } を受け取ります。length プロパティはありません)。iv は正確に 12 バイトである必要があり、128 ビットの Poly1305 認証タグが暗号テキストに追加されます。
  • 'AES-GCM': generateKey() または deriveKey() を呼び出す場合は { name: 'AES-GCM', length: 256 } を渡し、encrypt() と decrypt() を呼び出す場合は 12 バイトの iv を渡します。
  • additionalData(AAD): 暗号化されていないメタデータ(レコード ID、受信者 ID、プロトコル バージョンなど)を 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' をまだサポートしていない場合は、静的 SubtleCrypto.supports() メソッドを使用してサポートを検出し、エフェメラル静的ディフィーヘルマン鍵合意('X25519' + 'HKDF' + 'AES-GCM'、または 'X25519' サポート前のブラウザをターゲットとする場合は 'P-256' を含む 'ECDH')にフォールバックします。

  • crypto.subtle ではなく SubtleCrypto コンストラクタで SubtleCrypto.supports() を呼び出す: 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);
}