🐶

Web Crypto API を使用して公開鍵方式の暗号化・復号を行う

2023/01/30に公開

こんにちは。株式会社スペースマーケットのwado63です。
サーバーを通さずブラウザ間のデータ連携安全にを行うために、Web Crypto APIについて調べたので、記事にまとめたいと思います。

MDNの内容が元になっておりますmm
https://developer.mozilla.org/ja/docs/Web/API/Web_Crypto_API

この記事で書くこと

  • Web Crypto APIを使った暗号化・復号の実装について

この記事で書かないこと

  • 暗号化の仕組みについて
  • ブラウザ間のデータ連携について

やること

  1. 公開鍵・秘密鍵を作成する
  2. 公開鍵を受け渡しできるようにbase64でencodeする
  3. 公開鍵を受け取った側で暗号化を行う
  4. 暗号化をした内容を秘密鍵で復号する

1.公開鍵・秘密鍵を作成する

generateKey()で行います。(MDN

第一引数には使用したい暗号化のアルゴリズムに応じたパラメーターを渡します。
今回は公開鍵暗号方式の1つである、RSA暗号を使用するのでRsaHashedKeyGenParamsを渡します。
第二引数はexportKey()などで鍵を取り出せるようにするかどうかのBoolean、
第三引数で生成した鍵を用いて何ができるかを決めます。

/**
 * RSA方式の公開・秘密鍵を生成する
 */
const generateKey: Promise<CryptoKeyPair> = () =>
  window.crypto.subtle.generateKey(
    {
      name: 'RSA-OAEP',
      modulusLength: 2048,
      publicExponent: new Uint8Array([1, 0, 1]),
      hash: 'SHA-256',
    },
    true,
    ['encrypt', 'decrypt'],
  )

(async () => {
  const keyPair: CryptoKeyPair = await generateKey() // 鍵の生成

  const publicKey: CryptoKey = keyPair.publicKey; // 公開鍵
  const privateKey: CryptoKey = keyPair.privateKey; // 秘密鍵
})();

RsaHashedKeyGenParamsについて

RSAの暗号方式についての理解が必要となります。
僕はこちらの記事で勉強させていただきました。

https://zenn.dev/lilac/articles/bad1900aa8f0c0

理解しておいた方がもちろん良いですが、初めのうちはこのような定数が入るんだなというぐらいの理解でもいいと思います。

①: name

アルゴリズムの方式で、RsaHashedKeyGenParams のパラメーターを渡す今回の例では
RSASSA-PKCS1-v1_5, RSA-PSS, RSA-OAEP が選べます。

②: modulusLength

RSAの法の長さ (bit数)を指定します。
最低2048bitが推奨されており、今回のサンプルは鍵を長期的に保存しないので最低の2048bitです。

③: publicExponent

公開指数です。 他の値を用いるいい理由がない限り65,537 ([0x01, 0x00, 0x01]) を使用します。
bit表記だと65,537が"10000000000000001"となるので推奨されているんですね。

④: hash

用いるダイジェスト関数の名前を表す文字列です。SHA-256, SHA-384, SHA-512 のうちのいずれかを指定できます。 SHA-256で256bitのダイジェスト値を出力します。
SHA-1も選べるようですが脆弱なため、SHA-256より上のものを選んでおくと良いです。

公開鍵を受け渡しできるようにbase64でencodeする

公開鍵を作りましたが、次はexportKey()を使って別の場所で使えるようにします。(MDN

exportKey()の第一引数には、exportする鍵のformatを指定します。
今回は公開鍵をexportするのでspki(SubjectPublicKeyInfo)を指定します。
第二引数には先ほど作った公開鍵を指定します。

返り値はArrayBufferとなっているので、textとして受け渡しできるようにstringに変換してからさらにbase64でencodeします。

ArrayBufferとstringの変換についてはMDNでも使用していた、
以下の記事の内容を参考に実装しています。
https://developer.chrome.com/blog/how-to-convert-arraybuffer-to-and-from-string/

/**
 * ArrayBufferをbase64にencodeする
 */
const arrayBufferToBase64: (buffer: ArrayBuffer) => string = (
  buffer,
) => {
  const str = String.fromCharCode.apply(
    null,
    new Uint8Array(buffer) as unknown as number[],
  )

  return window.btoa(str)
}

/**
 * 公開鍵をbase64にencodeする
 */
const publicKeyToBase64: (
  publicKey: CryptoKey,
) => Promise<string> = async (publicKey) => {
  const key = await window.crypto.subtle.exportKey('spki', publicKey)

  return arrayBufferToBase64(key)
}

(async () => {
  const keyPair: CryptoKeyPair = await generateKey() // 鍵を生成

  const base64PublicKey: string = publicKeyToBase64(keyPair.publicKey) // 公開鍵をbase64にencode
})();

公開鍵を受け取った側で暗号化を行う

次はbase64でencodeされた公開鍵を使用し、任意の文字列を暗号化します。

外部から受け取った暗号鍵を使用するためにはimportKey()を使用します。(MDN)

importKey()の第一引数はexportした際のformatなのでspkiを指定。
第二引数は公開鍵のArrayBufferを指定する必要があるので、base64を元のArrayBufferに変換しています。
第三引数には鍵生成の時のアルゴリズムに応じたパラメーターを指定します。今回はRSA暗号を使用しているのでRsaHashedImportParamsです。鍵生成時に指定したRsaHashedKeyGenParamsと似た内容なので分かりやすいですね。
第四引数、第五引数は、generateKey()と同じようにexportできるかどうかと、利用用途を指定します。

/**
 * base64をArrayBufferに変換する
 */
const base64ToArrayBuffer: (base64: string) => ArrayBuffer = (
  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
}

/**
 * base64でencodeされた公開鍵をimportする
 */
const importBase64PublicKey: (
  base64PublicKey: string,
) => Promise<CryptoKey> = async (base64PublicKey) => {
  const key = await window.crypto.subtle.importKey(
    'spki',
    base64ToArrayBuffer(base64PublicKey),
    {
      name: 'RSA-OAEP',
      hash: 'SHA-256',
    },
    false,
    ['encrypt'],
  )

  return key
}

次にencrypt()を用いて暗号化を行います。(MDN)
encrypt()の第一引数はアルゴリズムを指定します。RSA暗号を使用しているので、RsaOaepParamsを指定します。

第二引数は公開鍵、第三引数にはBufferSourceが入ります。
今回はテキストを暗号化したいので、TextEncoderでencodeして作ったUint8Arrayを渡しています

encryptの返り値はArrayBufferなので、これをstringとして渡しやすいbase64に変換しておきます。

/**
 * 暗号化を行う
 */
const encryptText: (
  text: string,
  publicKey: CryptoKey,
) => Promise<string> = async (text, publicKey) => {
  const encoded = new TextEncoder().encode(text)

  const encryptedBuffer = await window.crypto.subtle.encrypt(
    {
      name: 'RSA-OAEP',
    },
    publicKey,
    encoded,
  )

  return arrayBufferToBase64(encryptedBuffer)
}

(async() => {
  const base64PublicKey = "MIIBIjANBgkqhkiG9w0BAQEF..." // 外部からもらったと仮定

  const publicKey = await importBase64PublicKey(base64PublicKey)
  const encryptedBase64Text = await encryptText("メッセージ", publicKey)
})()

暗号化をした内容を秘密鍵で復号する

最後に暗号化した内容を復号します。
復号にはdecrypt()(MDN)を使用します。

先ほどの暗号化した値を複合してみます。

decrypt()の第一引数はアルゴリズム、RSA暗号なので今回はRsaOaepParamsを指定します。
第二引数は秘密鍵、第三匹数は復号するBufferSourceです。

/**
 * 復号を行う
 */
const decryptText: (
  encryptedBase64Text: string,
  privateKey: CryptoKey,
) => Promise<string> = async (encryptedBase64Text, privateKey) => {
  const decryptedBuffer = await window.crypto.subtle.decrypt(
    {
      name: 'RSA-OAEP',
    },
    privateKey,
    base64ToArrayBuffer(encryptedBase64Text),
  )

  return new TextDecoder().decode(decryptedBuffer)
}

(async() => {
  const keyPair = await generateKey()
 
  // 公開鍵を外部に渡して、暗号化したテキストを受け取る
  
  const encryptedBase64Text = "wz5dKcjoCui20Bl/Z..." // 外部からもらったと仮定
  
  const decryptedText = await decryptText(encryptedBase64Text, keyPair.privateKey)
})()

まとめ

これで鍵の生成、暗号化、復号の一連の処理ができました。
慣れない用語やBufferの変換などで最初は戸惑うと思いますが、理解してしまうと意外シンプルな処理でした。

あとは公開鍵の受け渡しや、暗号化したテキストの受け渡しの仕組みを用意すればブラウザ間の通信を安全に行うことができますね。

最後に一連の処理をまとめたものを置いておきます。
鍵の生成、export、import、暗号化、復号を行っています。

/**
 * RSA方式の公開・秘密鍵を生成する
 */
const generateKey: () => Promise<CryptoKeyPair> = () =>
  window.crypto.subtle.generateKey(
    {
      name: 'RSA-OAEP',
      modulusLength: 2048,
      publicExponent: new Uint8Array([1, 0, 1]),
      hash: 'SHA-256',
    },
    true,
    ['encrypt', 'decrypt'],
  )

/**
 * ArrayBufferをbase64にencodeする
 */
const arrayBufferToBase64: (buffer: ArrayBuffer) => string = (
  buffer,
) => {
  const str = String.fromCharCode.apply(
    null,
    new Uint8Array(buffer) as unknown as number[],
  )

  return window.btoa(str)
}

/**
 * base64をArrayBufferに変換する
 */
const base64ToArrayBuffer: (base64: string) => ArrayBuffer = (
  base64,
) => {
  const str = window.atob(base64)
  const buf = new ArrayBuffer(str.length)
  const bufView = new Uint8Array(buf)
  // eslint-disable-next-line no-plusplus
  for (let i = 0, strLen = str.length; i < strLen; i++) {
    bufView[i] = str.charCodeAt(i)
  }
  return buf
}
  
/**
 * 公開鍵をbase64にencodeする
 */
const publicKeyToBase64: (
  publicKey: CryptoKey,
) => Promise<string> = async (publicKey) => {
  const key = await window.crypto.subtle.exportKey('spki', publicKey)

  return arrayBufferToBase64(key)
}

/**
 * base64でencodeされた公開鍵をimportする
 */
const importBase64PublicKey: (
  base64PublicKey: string,
) => Promise<CryptoKey> = async (base64PublicKey) => {
  const key = await window.crypto.subtle.importKey(
    'spki',
    base64ToArrayBuffer(base64PublicKey),
    {
      name: 'RSA-OAEP',
      hash: 'SHA-256',
    },
    false,
    ['encrypt'],
  )

  return key
}

/**
 * 暗号化を行う
 */
const encryptText: (
  text: string,
  publicKey: CryptoKey,
) => Promise<string> = async (text, publicKey) => {
  const encoded = new TextEncoder().encode(text)

  const encryptedBuffer = await window.crypto.subtle.encrypt(
    {
      name: 'RSA-OAEP',
    },
    publicKey,
    encoded,
  )

  return arrayBufferToBase64(encryptedBuffer)
}

/**
 * 復号を行う
 */
const decryptText: (
  encryptedBase64Text: string,
  privateKey: CryptoKey,
) => Promise<string> = async (encryptedBase64Text, privateKey) => {
  const decryptedBuffer = await window.crypto.subtle.decrypt(
    {
      name: 'RSA-OAEP',
    },
    privateKey,
    base64ToArrayBuffer(encryptedBase64Text),
  )

  return new TextDecoder().decode(decryptedBuffer)
}

(async () => {
  const keyPair = await generateKey();
  const publicKeyText = await publicKeyToBase64(keyPair.publicKey);
  const publicKey = await importBase64PublicKey(publicKeyText);

  const message = "あいうえお";

  const encryptedText = await encryptText(
    message,
    publicKey
  );
  const decryptedText = await decryptText(
    encryptedText,
    keyPair.privateKey
  );

  console.log(decryptedText); // "あいうえお"
})();

余談

今回の処理をテストに乗せたかったですが、Web Crypto APIはNode v19からfull supportとなるので今回は諦めました🫠 Node v19であれば今回のコードがNode.js上でも動かせます。

宣伝

スペースマーケットでは、一緒にサービスを成長させていくメンバーを募集中です。

開発者だけでなくビジネスサイドとも距離感が近くて、風通しがとてもよい会社ですのでとても仕事がしやすいです。

とりあえずどんなことしているのか聞いてみたいという方も大歓迎です!
ご興味ありましたら是非ご覧ください!

https://www.wantedly.com/projects/1113570
https://www.wantedly.com/projects/1113544
https://www.wantedly.com/projects/1061116

https://spacemarket.co.jp/recruit/engineer/

スペースマーケット Engineer Blog

Discussion