Web Crypto API を使用して公開鍵方式の暗号化・復号を行う
こんにちは。株式会社スペースマーケットのwado63です。
サーバーを通さずブラウザ間のデータ連携安全にを行うために、Web Crypto APIについて調べたので、記事にまとめたいと思います。
MDNの内容が元になっておりますmm
この記事で書くこと
- Web Crypto APIを使った暗号化・復号の実装について
この記事で書かないこと
- 暗号化の仕組みについて
- ブラウザ間のデータ連携について
やること
- 公開鍵・秘密鍵を作成する
- 公開鍵を受け渡しできるようにbase64でencodeする
- 公開鍵を受け取った側で暗号化を行う
- 暗号化をした内容を秘密鍵で復号する
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の暗号方式についての理解が必要となります。
僕はこちらの記事で勉強させていただきました。
理解しておいた方がもちろん良いですが、初めのうちはこのような定数が入るんだなというぐらいの理解でもいいと思います。
①: 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でも使用していた、
以下の記事の内容を参考に実装しています。
/**
* 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上でも動かせます。
宣伝
スペースマーケットでは、一緒にサービスを成長させていくメンバーを募集中です。
開発者だけでなくビジネスサイドとも距離感が近くて、風通しがとてもよい会社ですのでとても仕事がしやすいです。
とりあえずどんなことしているのか聞いてみたいという方も大歓迎です!
ご興味ありましたら是非ご覧ください!
スペースを簡単に貸し借りできるサービス「スペースマーケット」のエンジニアによる公式ブログです。 弊社採用技術スタックはこちら -> whatweuse.dev/company/spacemarket
Discussion