Carpe Diem

備忘録

データ暗号化で考慮すること(暗号アルゴリズム編)

概要

ユーザのメールアドレスといった機密情報を暗号化・復号したいユースケースがあるとします。
メール送信などに利用するためパスワードのようにハッシュ化するのでなく、復号して利用したいケースです。

機密情報の管理全般については以前↓で話しましたが、今回はデータの暗号化に焦点を当ててみます。

機密情報の管理で大切なこと - Carpe Diem

前提知識

適切な暗号方法を理解していないとZoomで問題になったように脆弱性のあるアルゴリズムを使用してしまいます。

Zoom documentation claims that the app uses “AES-256” encryption for meetings where possible. However, we find that in each Zoom meeting, a single AES-128 key is used in ECB mode by all participants to encrypt and decrypt audio and video. The use of ECB mode is not recommended because patterns present in the plaintext are preserved during encryption.

ref: Move Fast and Roll Your Own Crypto: A Quick Look at the Confidentiality of Zoom Meetings - The Citizen Lab

なので暗号における前提知識を説明した後に具体的な実装を紹介します。

強い暗号アルゴリズムとは

強い暗号アルゴリズムとは

  • アルゴリズムの詳細が公開されている
  • 多数のサンプルが用意されている
  • 専門の暗号解読者が時間をかけて検証している

という前提があっても解読できない、もしくは解読するには非常に長い時間がかかる(=現実的な時間で解読できない)ものです。
なのでコンペ方式で標準化されたAESSHA-3などは良いアルゴリズムと言えます。

逆に独自のアルゴリズムであったり、十分に検証されていないアルゴリズムは基本的に避けた方が良いです。

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

ブロック暗号

ブロック暗号は平文のデータを固定長に区切り、その区切った単位で暗号化します。 例えばDESは64bits、AESでは128bitsの長さでデータを区切って暗号化します。

ストリーム暗号

ストリーム暗号はブロック暗号のように固定長ではなくビット単位あるいはバイト単位などで逐次暗号化します。
ブロック暗号と違って入力がブロックサイズになるまで処理を待つ必要がありません。しかしどこまで暗号化・復号したのか状態を保つ必要はあります。

ブロック暗号ほど安全性に対する研究は進んでおらず、CFB、OFB、CTRのようなブロック暗号に基づくストリーム暗号を利用した方が良いという考えもあります。

モード

ブロック暗号は固定長に区切ったブロックをその暗号アルゴリズムで繰り返し暗号化していくわけですが、この繰り返し暗号化する方法のことをモードといいます。

主なモードは以下です。省略前の名前がそのモードの特徴を示すので覚えておきましょう。

モード 特徴
ECB
(Electric CodeBook)
平文ブロックを暗号化したものがそのまま暗号ブロックになる。
平文の中の繰り返しが暗号文に現れる、暗号を解読せずに平文を操作できてしまうといった点で使うべきでない。
CBC
(Cipher Block Chaining)
1つ前の暗号文ブロックと平文ブロックのXORをとってから暗号化する。
最初の平文ブロックはIV(初期化ベクトル)を使ってXORをとる。
CFB
(Cipher-FeedBack)
1つ前の暗号文ブロックを暗号化し、平文ブロックとのXORをとる。
最初の平文ブロックはIV(初期化ベクトル)を暗号化してXORををとる。
OFB
(Output-FeedBack)
1つ前の暗号アルゴリズムの出力を暗号化し、平文ブロックとのXORをとる。
最初の平文ブロックはIV(初期化ベクトル)を暗号化してXORをとる。
CTR
(CounTeR)
カウンタを暗号化し、平文ブロックとのXORをとる。
カウンタの初期値はnonceを元にして作る。

ECBの「平文の中の繰り返しが暗号文に現れる」を示すのが以下の画像です。暗号化はしているものの同じデータは同じ暗号文になってしまい平文が予測しやすくなってしまいます。

f:id:quoll00:20201007232733p:plain

ref: Block cipher mode of operation - Wikipedia

なのでモードの選択も重要であることが分かります。

鍵ストリーム

CFB, OFB, CTRは平文を直接暗号化していません
暗号化しているのはIVや暗号文ブロックであり、平文はそれらとのXORをとっているだけです。

それら暗号化したものを鍵ストリームと呼びます。

なのでEBCやCBCのように平文ブロックのサイズに影響されず(固定長にするためのパディングが不要)、平文データを1ビットずつ暗号化することもできるのでブロック暗号を使ってストリーミング暗号を作っているとも言えます。

認証付き暗号(AEAD: Authenticated Encryption with Associated Data)

暗号文にMAC(メッセージ認証符号)を組み合わせることで完全性(暗号文が改ざんされていない)と認証性(共通鍵を持った人が作った)を担保できます。

CTRモードにこの「認証」の機能を追加したものをGCMモード(Galois/Counter Mode)と言います。

何を使えばいいか

AESのGCMモードが良いです。

AESは2000年にコンペ方式で標準化されたアルゴリズムであり、CTRモードも1979年に論文に提出され十分に研究されていると言えます。
したがって先に述べたように専門家が十分に検証した強いアルゴリズムと言えます。

GCMはそれにMACを組み合わせて完全性と認証性を加えたものなので、一般的な暗号化では十分と言えます。

具体的な実装

GCMによる暗号化と復号

暗号化

func Encrypt(key, plaintext []byte) ([]byte, error) {
        // アルゴリズムの選択。AESを使う。鍵は引数に。
        cb, err := aes.NewCipher(key)
        if err != nil {
                return nil, err
        }
        // モードの選択。GCMを使う
        gcm, err := cipher.NewGCM(cb)
        if err != nil {
                return nil, err
        }
        // 擬似乱数生成から12bytesのnonceを生成
        nonce := make([]byte, gcm.NonceSize())
        if _, err = rand.Read(nonce); err != nil {
                return nil, err
        }
        // 第二引数のnonceはnonceとして。
        // 第一引数のnonceは暗号文にnonceが連結されるように。
        ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
        return ciphertext, nil
}

ポイントはコメントで説明していますが

  1. 暗号アルゴリズムの選択
  2. モードの選択
  3. nonceの用意

とAESのCTRモードの通りに進めてます。
nonceは復号時に必要になるため暗号データに連結する必要がありますが、gcm.Seal()の第一引数に渡すと自動で連結してくれます。

nonceを擬似乱数生成器で生成する際の注意点

誕生日のパラドクスの関係で、長さL bitsで乱数生成した場合、2L/2回生成すると衝突率が50%を超え始めます

つまり12bytesのIVは擬似乱数生成器で248回以上生成すると衝突率が50%を超えてきます。

AESのGCMモードで暗号化する場合、同一鍵を使用しているかつIVが再利用されると実用的な攻撃が可能になってしまうため

  1. 暗号化毎に異なる鍵を利用する
  2. 生成回数が2L/2回以下の要件でのみ利用する
  3. IVに乱数でなく、カウンター値を利用する

の少なくともどれか1つは満たすよう考慮して利用しましょう。

復号

func Decrypt(key, data []byte) ([]byte, error) {
        // AESを使う。鍵は引数に。
        cb, err := aes.NewCipher(key)
        if err != nil {
                return nil, err
        }
        // GCMモードを使う
        gcm, err := cipher.NewGCM(cb)
        if err != nil {
                return nil, err
        }
        // データからnonceと暗号文を分割
        nonce, ciphertext := data[:gcm.NonceSize()], data[gcm.NonceSize():]
        plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
        if err != nil {
                return nil, err
        }
        return plaintext, nil
}

ポイントは暗号化時に連結したnonceと暗号文を分割してからgcm.Open()を呼ぶ点です。

鍵の生成

鍵の詳細については次回に述べることにします。
AESは128bits(=16bytes), 192bits(=24bytes), 256bits(=32bytes)の鍵長を選べます。
鍵長が長いほど(=鍵空間が大きいほど)総当たり攻撃が困難になるので256bits(=32bytes)にします。

func GenerateKey() ([]byte, error) {
        key := make([]byte, 32)
        if _, err := rand.Read(key); err != nil {
                return nil, err
        }
        return key, nil
}

実行

以下のmain関数で実行してみます。

func main() {
        data := []byte("secret text")
        key, err := GenerateKey()
        if err != nil {
                log.Fatal(err)
        }

        ciphertext, err := Encrypt(key, data)
        if err != nil {
                log.Fatal(err)
        }
        fmt.Printf("ciphertext: %s\n", hex.EncodeToString(ciphertext))

        plaintext, err := Decrypt(key, ciphertext)
        if err != nil {
                log.Fatal(err)
        }

        fmt.Printf("plaintext: %s\n", plaintext)
}

結果

$ go run main.go
ciphertext: 0d66feaccc97f4caf52d99586d37bd1e6d170318c9cb6557862ab314ef178c3853b759347ab866
plaintext: secret text

きちんと暗号化・復号できました。

参考