- ブログ
暗号化でデータを守る
2023.08.07 Mon
最近フロントエンドもガリガリ書いておりますバックエンドエンジニアのikka(ikkaという名前ではありますが「まあ、いっか」は私の辞書にはございません!)です。
あれこれ面白い記事を思案してみた結果、今回は、万人が興味を持つであろう暗号化&復号化(公開鍵と秘密鍵のキーペアによる)をテーマにまとめてみたいと思います。
ちなみに、開発環境は、Mac(Apple M2チップ) + Laravel 10 + Vue 3 を使用しています。(余談ですが、ホエールテックは全員Macをメインマシンとして使用しています。そして、なんでかPC移行を渋っている代表を除いて全員Apple Siliconです。)
何がしたいのか
先日、とあるシステムを作ることになったのですが、要件に沿って、いくつかの方法を提案したところ、そのうちのひとつ、データを暗号化してデータベースに保存する方法が採用されました。
当初は、サーバサイドで暗号化した(読めなくした)データをデータベースへ格納した上、そのデータを同じくサーバサイドで復号化して(読めるようにして)ダウンロードする仕組みを考えていたのですが、この場合には、一時的に復号化されたデータがサーバー上に存在することになるので、より安全な方法として、復号化はクライアントサイド(ブラウザ側)で行う方法を採用しました。
今回の方法の弱点は、復号化に使用する秘密鍵をサーバー側に保存せず、お客様自身で保管することになるのですが、万が一お客様が秘密鍵を紛失してしまった場合には、データの復号化が不可能になってしまうのです。この点は運用時に最大限の注意が必要となります。
もう一つ、ブラウザ側での復号化の処理速度が気になるポイントです。サーバーサイドでの復号化と比較すると、遅くなる感は否めません。ikka(ikkaという名前ではありますが「まあ、いっか」は私の辞書にはございません!)としましては、少しでも高速化し、お客様に快適にご利用いただけるよう努めてまいる所存です!
それでは、キーペア作成からスタート
サーバサイドでの暗号化処理はOpenSSL関数を使用します。
まず、新しい秘密鍵のインスタンスを作成します。
public static function createKey(?array $options = [
'digest_alg' => 'sha256',
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]): OpenSSLAsymmetricKey | false
{
return openssl_pkey_new($options);
}作成したインスタンスから、公開鍵と秘密鍵の文字列を取得します。
public static function getPublicKey(OpenSSLAsymmetricKey $instance): string | false
{
$details = openssl_pkey_get_details($instance);
if ($details === false) {
return false;
}
return $details['key'];
}
public static function getPrivateKey(OpenSSLAsymmetricKey $instance): string | false
{
$privateKey = '';
if (openssl_pkey_export($instance, $privateKey)) {
return $privateKey;
} else {
return false;
}
}公開鍵はサーバで保管し暗号化に使用し、秘密鍵はクライアントに返して復号に使用します。
サーバ上でデータを暗号化する
openssl_public_encrypt()により暗号化されたデータはそのままだと保存に適さないため、base64エンコードを行います。
public static function publicKeyEncrypt(string $text, OpenSSLAsymmetricKey | string $publicKey): string | false
{
$encrypted = '';
$publicKeyString = ($publicKey instanceof OpenSSLAsymmetricKey) ? static::getPublicKey($publicKey) : $publicKey;
if ($publicKeyString === false) {
return false;
}
if (openssl_public_encrypt($text, $encrypted, $publicKeyString)) {
return base64_encode($encrypted);
} else {
return false;
}
}実はこのコードには問題があります。
暗号化可能なデータの最大長は秘密鍵を生成した際のビット数によって決まっており、それを超えるとopenssl_public_encrypt()はfalseを返します。
最大長(バイト) = (秘密鍵のビット数 / 8) – 11
「じゃあ秘密鍵のビット数を大きくすればいいじゃん!」と思うかもしれませんが、ビット長を大きくすると加速度的に処理時間が増加します。
| 操作↓ 秘密鍵のビット数→ | 1024 | 2048 | 4096 | 8192 |
| キーペア生成 | 121 | 125 | 538 | 27920 |
| 32バイトを100回暗号化 | 37 | 39 | 43 | 62 |
| 64バイトを100回暗号化 | 61 | 39 | 44 | 62 |
| 128バイトを100回暗号化 | 失敗 | 39 | 43 | 65 |
| 256バイトを100回暗号化 | 失敗 | 失敗 | 43 | 61 |
| 32バイトを100回復号 | 55 | 113 | 462 | 2714 |
| 64バイトを100回復号 | 57 | 113 | 448 | 2715 |
| 128バイトを100回復号 | – | 113 | 445 | 2698 |
| 256バイトを100回復号 | – | – | 451 | 2692 |
ビット数を4096以上にするよりも、ビット数2048で複数回処理する方が早いことが分かります。そのため、データを暗号化可能な範囲で分割、それぞれ暗号化した文字列を連結して保存するようにします。
※ なお、NIST SP 800-57 Part 1 Rev. 5 (鍵管理に関する推奨事項)によると、RSA 2048ビット=セキュリティ強度112ビットは、2030年12月31日までの期間で利用可能であり、2031年1月1日をこえて同じ暗号化データを取り扱う場合には非推奨となります。暗号化を取り扱う場合には、速度だけでなく、セキュリティ強度も勘案すべき内容です。
public static function publicKeySplitEncrypt(string $text, OpenSSLAsymmetricKey | string $publicKey, ?int $keyLength = 2048): string | false
{
// 1回に暗号化可能なバイト数を算出
$maxBytes = floor(($keyLength / 8) - 11);
if ($maxBytes <= 0) {
return false;
}
// 文字列をバイト単位で区切る
$target = $text;
$splitText = [];
while(true) {
// mb_strcut()は指定されたバイト数に入る分だけ、マルチバイト文字が壊れないように切り出してくれる
$cutted = mb_strcut($target, 0, $maxBytes);
$splitText[] = $cutted;
if ($cutted == $target) {
break;
}
$target = mb_substr($target, mb_strlen($cutted));
}
// それぞれ暗号化処理を行う
$encryptArray = [];
foreach ($splitText as $text) {
$encrypted = static::publicKeyEncrypt($text, $publicKey);
if ($encrypted === false) {
return false;
}
$encryptArray[] = $encrypted;
}
return implode('', $encryptArray);
}base64エンコードされた文字列は==で終わるので、復号時はこれを目印に分割していきます。
ブラウザ上でデータを復号する
今回、復号処理にはjsencryptを使用します。(Web Crypto APIも試したのですが、ブラウザ依存の問題もあり、使用を断念しました。)
const decrypt = (enc: string, privateKey: string): string | false => {
const jsEncrypt = new JSEncrypt();
jsEncrypt.setPrivateKey(privateKey);
let ret = "";
// base64エンコード文字列の末尾にある==を目印に分割
const encArray = enc.split("==");
for (let element of encArray) {
if (element == "") {
break;
}
const decrypted = jsEncrypt.decrypt(element + "==");
if (decrypted === false) {
// 復号に失敗
return false;
}
ret += decrypted;
}
return ret;
};
なお、私の環境では1回の復号に約15msを要しました。
(2023/9/22 修正:空文字を暗号化した文字列がエラーになることから、falseを返すように変更)(2023/12/04 修正:ループ処理をarray.foreachをfor…ofに変更)
大きな、もしくは多量の暗号化データを扱う場合、描画処理が長い間停止してしまいます。これを解決するためのWeb Workerについては、またの機会に書こうと思います。
それでは、みなさま、ikka(ikkaという名前ではありますが「まあ、いっか」は私の辞書にはございません!)でした。