Web Crypto API（SubtleCrypto）って何？文字列を暗号化してみる

:::note warn
この記事は暗号化に全く詳しくない人が書いています。
遊びで使うなら問題ないと思いますが、実用する際は決して内容を鵜呑みにせず、専門家を頼ってください。
:::

# Web Crypto APIとは

[Web Crypto API](https://developer.mozilla.org/ja/docs/Web/API/Web_Crypto_API)は、ざっくり言うとWeb上で簡単な暗号化ができるAPIです。

これには`window.crypto`からアクセスできます。
`window.crypto`は[`Crypto`インターフェース](https://developer.mozilla.org/ja/docs/Web/API/Crypto)のオブジェクトになっていて、`randomUUID`メソッドや`subtle`プロパティを持っています。

この記事では、`subtle`プロパティに入っている`SubtleCrypto`に焦点を当てて解説します。

## SubtleCrypto

[`SubtleCrypto`](https://developer.mozilla.org/ja/docs/Web/API/SubtleCrypto)は、実際に暗号化などの処理が定義されているインターフェースです。
`window.crypto.subtle`から利用できます。

ここでは以下の処理を紹介します。

- 鍵の生成
- 文字列の暗号化 / 復号

:::note
ここでは`RSA-OAEP`という[アルゴリズム](https://developer.mozilla.org/ja/docs/Web/API/SubtleCrypto#対応しているアルゴリズム)に絞って紹介します。

`RSA-OAEP`は**公開鍵暗号**と呼ばれる暗号方式のアルゴリズムです。
公開鍵暗号には公開鍵と秘密鍵があり、**公開鍵で文書を暗号化**し、それを**秘密鍵で復号**します。

公開鍵暗号の詳細は[こちら](https://udemy.benesse.co.jp/development/blockchain/public-key-cryptography.html)がわかりやすいと思います。
:::

# 鍵の生成
文字列を暗号化したり復号するには**鍵**が必要です。
鍵の生成には[`generateKey`](https://developer.mozilla.org/ja/docs/Web/API/SubtleCrypto/generateKey)を使用します。

`generateKey`関数は[`CryptoKeyPair`](https://developer.mozilla.org/ja/docs/Web/API/CryptoKeyPair)オブジェクトで履行されるプロミスを返します。
`CryptoKeyPair`は公開鍵と秘密鍵のペアで、以下のプロパティを持っています。

- `publickey`: 公開鍵を示す[`CryptoKey`](https://developer.mozilla.org/ja/docs/Web/API/CryptoKey)オブジェクト
- `privateKey`: 秘密鍵を示す`CryptoKey`オブジェクト

## 使ってみる
`generateKey`は以下の引数を取ります。

1. `algorithm`: 鍵のアルゴリズム
1. `extractable`: 鍵をエクスポートできるかどうか、今回は`true`
1. `keyUsages`: 生成した鍵で何ができるかを入れる配列
    - `algorithm`に対応していない文字列を入れるとエラーになる
    - `RSA-OAEP`は`encrypt`（暗号化）と`decrypt`（復号）ができる

`RSA-OAEP`の場合、1の`algorithm`には[`RsaHashedKeyGenParams`](https://developer.mozilla.org/ja/docs/Web/API/RsaHashedKeyGenParams)オブジェクトを入れます。

例えば以下のように呼び出せます。

```js
// RsaHashedKeyGenParamsオブジェクト
const algorithm = {
  name: 'RSA-OAEP', // アルゴリズムの名前
  modulusLength: 4096, // 法（？）の長さ
  publicExponent: new Uint8Array([0x01, 0x00, 0x01]), // 公開指数（？）
  hash: 'SHA-256' // ダイジェスト関数に使うアルゴリズム名
}

// generateKeyで鍵を生成する
const keyPair = await window.crypto.subtle.generateKey(
  algorithm,
  true, // エクスポートできるようにする
  ['encrypt', 'decrypt'] // 暗号化と復号に使う
)

keyPair.publicKey // CryptoKey
keyPair.privateKey // CryptoKey
```

# 暗号化 / 復号
ここからは`encrypt`や`decrypt`関数を使って、実際に文字列を暗号化したり復号する処理を書いていきます。

## 暗号化
暗号化には[`encrypt`](https://developer.mozilla.org/ja/docs/Web/API/SubtleCrypto/encrypt)関数を使います。
`encrypt`を使うと、暗号化する前の文章（平文）である`ArrayBuffer`から、暗号化した`ArrayBuffer`を取得できます。

この関数は以下の引数を取ります。

1. `algorithm`: ここでは[`RsaOaepParams`](https://developer.mozilla.org/en-US/docs/Web/API/RsaOaepParams)オブジェクト
1. `key`: 暗号化に使う公開鍵の`CryptoKey`オブジェクト
1. `data`: 暗号化したい`ArrayBuffer`

暗号化したい文字列を`ArrayBuffer`に変換するには[`TextEncoder`](https://developer.mozilla.org/ja/docs/Web/API/TextEncoder/encode)が使えます。

```js
// RSA-OAEPの鍵を生成する
const keyPair = await window.crypto.subtle.generateKey(/* ... */)

// 暗号化したいデータ
const text = 'Hello World!'
const textBuffer = new TextEncoder().encode(text)

// encryptで暗号化する
const cipherBuf = await window.crypto.subtle.encrypt(
  { name: 'RSA-OAEP' }, // algorithm
  keyPair.publicKey,
  textBuffer
)

cipherBuf // 暗号化されたArrayBuffer
```

## `base64`と`arrayBuffer`を変換する
暗号化した`ArrayBuffer`が生成できたところで、次はこれを[Base64](https://developer.mozilla.org/ja/docs/Glossary/Base64)文字列に変換します。
これはバイナリのデータをASCII文字列にする感じです。

といっても現在のWeb APIには直接変換する方法がないので、変換する関数を自分で定義します。
ここでは、[こちらの記事](https://zenn.dev/spacemarket/articles/9357ba7de0c9d3)のコードから型定義を取って使うことにします。

`arrayBufferToBase64`: `ArrayBuffer`をBase64文字列に変換する関数

```js
function arrayBufferToBase64(buffer) {
  const str = String.fromCharCode.apply(
    null,
    new Uint8Array(buffer),
  )

  return window.btoa(str)
}

// 呼び出し例
const cipherBuf = await window.crypto.subtle.encrypt(/* ... */)
arrayBufferToBase64(cipherBuf) // 例: WPlIHJOKjTmzxq...
```

`base64ToArrayBuffer`: Base64文字列を`ArrayBuffer`に変換する関数

```js
function base64ToArrayBuffer(base64) {
  const str = window.atob(base64)
  const buf = new ArrayBuffer(str.length)
  const bufView = new Uint8Array(buf)
  for (let i = 0, strLen = str.length; i < strLen; i++) {
    bufView[i] = str.charCodeAt(i)
  }
  return buf
}

// 呼び出し例
const cipherText = // 暗号文のBase64文字列
base64ToArrayBuffer(cipherText) // ArrayBuffer
```

## 復号
暗号化された`ArrayBuffer`を復号するには[`decrypt`](https://developer.mozilla.org/ja/docs/Web/API/SubtleCrypto/decrypt)関数を使います。

`decrypt`は以下の引数を取ります。

- `algorithm`: `RsaOaepParams`オブジェクト
- `key`: 復号に使用する秘密鍵を示す`CryptoKey`
- `data`: 暗号化されている`ArrayBuffer`

例えば、以下のように呼び出せます。

```js
// RSA-OAEPの鍵
const keyPair = // ...

// keyPairの公開鍵で暗号化されたArrayBuffer
const cipherBuf = base64ToArrayBuffer(/* ... */)

// 復号
const textBuf = await window.crypto.subtle.decrypt(
  { name: 'RSA-OAEP' },
  keyPair.privateKey,
  cipherBuf
) // ArrayBuffer
```

## 通しで呼び出す
ということで、ここまでの関数を通しで呼び出してみようと思います。

とその前に、ここで一度、平文の文字列から暗号文（Base64）ができるまで、そして暗号文が平文に復号されるまでの過程を整理します。

これは大まかに以下のシーケンス図のようになります。

```mermaid
sequenceDiagram

participant textStr as 平文（string）
participant textBuf as 平文（ArrayBuffer）
participant cipherBuf as 暗号（ArrayBuffer）
participant cipherStr as 暗号（Base64）

textStr ->> textBuf : TextEncoder.encode
textBuf ->> cipherBuf : encrypt
cipherBuf ->> cipherStr : arrayBufferToBase64

cipherStr ->> cipherBuf : base64ToArrayBuffer
cipherBuf ->> textBuf : decrypt
textBuf ->> textStr : TextDecoder.decode
```

ということで、次はこれをコードにしてみます。
コードでは以下のことをやってみます。

1. 鍵を生成する
1. 文字列を一度暗号化する
1. 2の文字列を復号して出力する

実際のコードはこちらです。

```js: 準備
// 鍵の生成に使うアルゴリズム
const keyGenAlgorithm = {
  name: 'RSA-OAEP',
  modulusLength: 4096,
  publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
  hash: 'SHA-256',
}

// 暗号化や復号に使うアルゴリズム
const encryptAlgorithm = { name: 'RSA-OAEP' }

// 鍵を生成する
const keyPair = await window.crypto.subtle.generateKey(
  keyGenAlgorithm,
  true, // エクスポートできるようにする
  ['encrypt', 'decrypt'] // 暗号化と復号ができるようにする
)
```

```js: 暗号化
// 0. 暗号化したい文章（平文）
const text = 'Hello Crypto!'

async function encrypt(text) {
  // 1. 平文をArrayBufferに変換
  const textBuf = new TextEncoder().encode(text)

  // 2. 暗号化
  const cipherBuf = await window.crypto.subtle.encrypt(
    encryptAlgorithm,
    keyPair.publicKey,
    textBuf
  )

  // 3. cipherBufをBase64文字列に変換
  const cipherBase64 = arrayBufferToBase64(cipherBuf)
  return cipherBase64
}

const cipher = await encrypt(text) // XwZXGbI7Inopw...
```

```js: 復号
// 0. 復号したいBase64文字列
const cipher = await encrypt(text)

async function decrypt(cipherBase64) {
  // 1. 暗号文をArrayBufferに変換
  const cipherBuf = base64ToArrayBuffer(cipherBase64)

  // 2. 復号
  const textBuf = await window.crypto.subtle.decrypt(
    encryptAlgorithm,
    keyPair.privateKey,
    cipherBuf
  )

  // 3. textBufを文字列に変換
  const text = new TextDecoder().decode(textBuf)
  return text
}

const text2 = await decrypt(cipher)
console.log(text2) // 'Hello Crypto!'
```