😇

Node.js における暗号化方式の選択肢

2025/02/12に公開

WED株式会社でバックエンドだったりフロントエンドだったりのエンジニアをやっている宮崎です。
基本的にはONEという、レシートがお金に変わるサービスに関わる開発全般を行なっています。

長いので結論

  1. マサカリ大歓迎
  2. AES-256-CBCを使いました
  3. GeminiはCBCモードが無難と言っていますが、ECBモードでなければよしなに使っていいと思います
  4. 都度ランダムな初期化ベクタを生成しましょう
  5. TypeScriptで書くと下記のようになります
const encrypt = (text: string, key: BinaryLike, iv: BinaryLike) => {
  const cipher = crypto.createCipheriv('aes-256-cbc', key, iv)
  let encrypted = cipher.update(text, 'utf8', 'hex')
  encrypted += cipher.final('hex')

  return encrypted
}

const decrypt = (encryptedText: string, key: BinaryLike, iv: BinaryLike) => {
  const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv)
  let decrypted = decipher.update(encryptedText, 'hex', 'utf8')
  decrypted += decipher.final('utf8')

  return decrypted
}

暗号(化)とは?

この記事をご覧になる皆様は当然ご存知のこととは思いますが、ある規則に則ってデータ(平文)を変換し秘匿することを指します。反対に暗号化されたデータを平文に戻すことを復号と言います。
ですが、日頃自身で暗号化処理を実装することはなく、あまり詳しくない・自信がないという方が多いのではないでしょうか?
私も結城浩氏の暗号技術入門を一通り読んだ程度で、例に漏れず自信がないエンジニアの一人です。

今回の記事を書くにあたって、なるべく正確な内容を心がけていますが、間違っている可能性は大いにあります。ですのでマサカリは大歓迎です。

ある日突然暗号化することになったら

なりました。
具体的には、我々のサービスを利用するクライアント企業の識別子と、クライアントサービス内でユーザを特定する識別子を秘匿することとなりました。
前述した通り自信はないですが、必要にかられた以上はやるしかありません。

選択した暗号化方式に合うようにライブラリを利用すればそれで終わりなんですが、私一人で実装してしまったこともあり、どういう理由でその方式を採用したのか、何を考えていたのかをチーム内にさえ広められていないので、メンバーへの知見共有と、暗号のさわりの部分の紹介を目的として筆を執りました。

暗号化方式の選択肢

さて、ひとえに暗号化方式と言っても様々なものがあります。
シーザー暗号を使ってもよいのであれば非常に楽なのですが、人力でも解けてしまいそうな暗号化方式はもちろん21世紀に使うべきではないでしょう。かの有名なエニグマも(私には十分わかりにくいですが)破られた実績があるので選択肢から外れます。

共通鍵暗号と公開鍵暗号

共通鍵暗号は読んで字のごとく、暗号化・復号ともに同じ鍵を使う方法です。一方公開鍵暗号は、対となる秘密鍵と公開鍵を用意し、片方で暗号化したものはもう一方でしか復号できないというものですね。新しいPCを手に入れるたびにGitHubに登録するアレです。httpsでは共通鍵を暗号化・復号するために公開鍵と秘密鍵のペアが使用されます。

今回の用途で後者を使う理由はあまりないと思われます。
後者は鍵の受け渡しを秘匿する必要がない(公開鍵が盗まれても復号ができないため)というメリットがありますが、今回のユースケースでは、我々が鍵を発行・管理する予定です。送信の方法にさえ気をつければ、鍵を十分セキュアに受け渡せると考えました。
また、今回どれほど問題になるかは不明ですが、公開鍵・秘密鍵による暗号化・復号は処理速度が遅いとされています。
httpsが共通鍵の暗号化時のみに公開鍵暗号を利用する、ハイブリッド暗号を利用している所以ですね。

ストリーム暗号とブロック暗号

ストリーム暗号は1ビットないしは1バイトずつ、逐次暗号化していく方式です。一方ブロック暗号は、データを固定ビット長ごとに分割したブロックそれぞれを暗号化していきます。
逐次暗号化するような用途ではないので、あまり悩む必要はなく後者を選ぶこととなりました。

DES(Data Encryption Standard)とAES(Advanced Encryption Standard)

DESは対応している鍵長が短く、コンピュータの性能が向上していくにつれ、強度が不十分となってしまいました。
そこで2回の暗号化を行う2DESや、異なる鍵で暗号化 -> 復号 -> 暗号化を行う3DESが生まれました。ところが、前者は解読が依然として容易で、後者は計算量の増加が無視できないことから、新しい標準暗号が求められました。
世界中から集められた候補の中からアメリカのNISTに選定されたのがラインダール暗号で、これをDESに次ぐ標準、AESとすることとなりました。

世界にはDESとAES以外の暗号化アルゴリズムももちろん存在するわけですが、特に理由がない限りはまずAESでいいと思います。

ちなみに、以下が私のローカル環境におけるNode.jsのcryptoモジュールで使用可能な暗号化方式です。
crypto.getCiphers()を実行すると出力されます。

list

大きく分けるとAES, DES, ARIA, Camellia, SM4がありますね。

aes-128-cbc
aes-128-ccm
aes-128-cfb
aes-128-cfb1
aes-128-cfb8
aes-128-ctr
aes-128-ecb
aes-128-gcm
aes-128-ocb
aes-128-ofb
aes-128-xts
aes-192-cbc
aes-192-ccm
aes-192-cfb
aes-192-cfb1
aes-192-cfb8
aes-192-ctr
aes-192-ecb
aes-192-gcm
aes-192-ocb
aes-192-ofb
aes-256-cbc
aes-256-ccm
aes-256-cfb
aes-256-cfb1
aes-256-cfb8
aes-256-ctr
aes-256-ecb
aes-256-gcm
aes-256-ocb
aes-256-ofb
aes-256-xts
aes128
aes128-wrap
aes192
aes192-wrap
aes256
aes256-wrap
aria-128-cbc
aria-128-ccm
aria-128-cfb
aria-128-cfb1
aria-128-cfb8
aria-128-ctr
aria-128-ecb
aria-128-gcm
aria-128-ofb
aria-192-cbc
aria-192-ccm
aria-192-cfb
aria-192-cfb1
aria-192-cfb8
aria-192-ctr
aria-192-ecb
aria-192-gcm
aria-192-ofb
aria-256-cbc
aria-256-ccm
aria-256-cfb
aria-256-cfb1
aria-256-cfb8
aria-256-ctr
aria-256-ecb
aria-256-gcm
aria-256-ofb
aria128
aria192
aria256
camellia-128-cbc
camellia-128-cfb
camellia-128-cfb1
camellia-128-cfb8
camellia-128-ctr
camellia-128-ecb
camellia-128-ofb
camellia-192-cbc
camellia-192-cfb
camellia-192-cfb1
camellia-192-cfb8
camellia-192-ctr
camellia-192-ecb
camellia-192-ofb
camellia-256-cbc
camellia-256-cfb
camellia-256-cfb1
camellia-256-cfb8
camellia-256-ctr
camellia-256-ecb
camellia-256-ofb
camellia128
camellia192
camellia256
chacha20
chacha20-poly1305
des-ede
des-ede-cbc
des-ede-cfb
des-ede-ecb
des-ede-ofb
des-ede3
des-ede3-cbc
des-ede3-cfb
des-ede3-cfb1
des-ede3-cfb8
des-ede3-ecb
des-ede3-ofb
des3
des3-wrap
id-aes128-CCM
id-aes128-GCM
id-aes128-wrap
id-aes128-wrap-pad
id-aes192-CCM
id-aes192-GCM
id-aes192-wrap
id-aes192-wrap-pad
id-aes256-CCM
id-aes256-GCM
id-aes256-wrap
id-aes256-wrap-pad
id-smime-alg-CMS3DESwrap
sm4
sm4-cbc
sm4-cfb
sm4-ctr
sm4-ecb
sm4-ofb

ブロック暗号のモード

下表はGeminiにまとめてもらいました。

モード 特徴 利点 欠点
ECB 平文ブロックごとに独立して暗号化を行う 並列処理が可能で高速 同じ平文ブロックは常に同じ暗号文ブロックに変換されるため、セキュリティ上の脆弱性がある
CBC 直前の暗号文ブロックと XOR 演算を行う セキュリティが高い 暗号化処理が逐次的になるため、並列処理ができない
CFB 暗号文ブロックをシフトレジスタに入力する ストリーム暗号のように扱うことができ、1 バイトずつの暗号化が可能 エラー伝搬が起こりやすい
OFB 暗号化関数の出力をシフトレジスタに入力する ストリーム暗号のように扱うことができ、並列処理が可能 エラー伝搬は起こらないが、暗号文の完全性が損なわれる可能性がある
CTR カウンタ値を暗号化して平文ブロックと XOR 演算を行う 並列処理が可能で高速。エラー伝搬が起こらない カウンタ値の管理が必要

ECBモードは同じデータを持つブロックを暗号化すると、同じものが出力されます。ブロック暗号としては最も単純で、それゆえに脆弱性があります。
その他のモードは同一データを持つブロックでも異なる出力をしてくれます。
IPAの暗号アルゴリズムの利用実績に関する調査報告書によると、CBCを選ぶことが多いようです。
欠点は並列処理ができないだけで、他のモードと比べてエラー伝搬もなくカウンタ値の管理なども不要なので、今回のケースではCBCモードがよさそうです。Geminiもそう言っていました。ちなみに次点でCTRモードがよいと思うそうです。

初期化ベクタ

ECB以外のモードを採用する場合、初期化ベクタが必要となります。
例えばCBCでのブロック暗号は2ブロック目以降、直前のブロックとのXORを計算します。
直前のブロックが存在しない1ブロック目はXORを計算できず、このままだと同じ平文・同じ鍵を利用する限りは同じ暗号を出力してしまいます。
なので1ブロック目の平文と暗号化結果が一度漏れた場合、鍵を盗まれていなくても情報が漏洩するおそれがあります。
その1ブロック目を守るために使われるのが初期化ベクタです。都度ブロックと同じ長さのランダムな値を生成し、CBCでは1ブロック目とのXORの計算相手となり、2ブロック目以降にもそれが伝播されます。

実装

以上をまとめてようやくコード(再掲)です。
随分長々と文章を連ねてしまいまいしたが、Node.jsのcryptoモジュールに任せるだけなので、別段難しいことはありません。
crypto.createCipheriv, crypto.createDecipherivの第一引数は、AESをCBCモードで使用することを表しています。
鍵長は256ビットです。この引数は任意ではなく、前述のcrypto.getCiphers()が出力する、対応している方式を使ってください。
第二引数は鍵そのものです。第三引数ivは初期化ベクタです。ブロック長と同じ長さのランダムな値を都度生成します。
cipher/decipher.updateの第一引数にはそれぞれ平文・暗号文が入ります。
第二・第三引数は入力時のエンコーディングと出力時のエンコーディングです。
cipher.updateにおいて出力時のエンコーディングに何も指定しない場合、Node.jsではBuffer型の値を返します。
そのままでは扱いにくいので16進数文字列表記にするために第三引数に'hex'を指定します。
decipher.updateではそれが逆になっています。
最後にcipher/decipher.finalをupdateと同じ出力形式で呼び出せばOKです。

const encrypt = (text: string, key: BinaryLike, iv: BinaryLike) => {
  const cipher = crypto.createCipheriv('aes-256-cbc', key, iv)
  let encrypted = cipher.update(text, 'utf8', 'hex')
  encrypted += cipher.final('hex')

  return encrypted
}

const decrypt = (encryptedText: string, key: BinaryLike, iv: BinaryLike) => {
  const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv)
  let decrypted = decipher.update(encryptedText, 'hex', 'utf8')
  decrypted += decipher.final('utf8')

  return decrypted
}

まとめ

実装時はさほど時間はかかりませんでしたが、この記事を書くにあたって色々と調査し直しなかなかの時間を要しました。
マサカリ大歓迎と言ってはいますが、やはり怖いので一度社内でレビューもしてもらいました。
それほどまでにこの分野は重要性が高く、その割に普段意識せずに恩恵を享けていることが多く、それゆえに専門家とそうでない人間の知識量に隔たりがあるからです。
とはいえ言語化してアウトプットする価値は、特に言語化した本人にとって大きく、脳内の周辺地図を整理できたという実感があります。
また、チームメンバーには日頃から「ハッシュ化は暗号化じゃないからね」程度の説明しかしていないので、「じゃあ暗号化って何なんだよ?いつ使うんだよ?」という当然の疑問が浮かんでいることと思います。
この記事は暗号というものを網羅的に説明することが目的ではありませんが、その疑問を解消する一助になればと思います。

WED Engineering Blog

Discussion