Go で DB にいれるデータを暗号化したい

センシティブなデータを DB に保存するにあたって、Go で暗号化を実装した。 その過程で暗号化についていろいろ調べたので、Go で暗号化したいけど暗号化のことはよくわからないという人を対象にまとめる。

今回は鍵の導出に PBKDF2 を、暗号アルゴリズムに AES-256-GCM を採用したのでこれをベースに説明していく1

PBKDF2

前提として、暗号化には鍵と呼ばれるデータが必要になる。 暗号文 (暗号化の結果) は、暗号アルゴリズムと平文、そして鍵の組み合わせで決定される。 また、鍵は復号にも用いられるため第三者に知られてはならない。

この鍵に human-readable な文字列を使うと辞書攻撃や総当たり攻撃に弱いという問題がある。 対策として、文字列のハッシュ化を繰り返し行うストレッチングと呼ばれる処理をすることで攻撃を困難にする。

PBKDF2 は任意のハッシュ関数に基づいて上記を行うアルゴリズムで、これによる鍵の導出の実装はこうなる。

package main

import (
    "crypto/sha256"

    "golang.org/x/crypto/pbkdf2"
)

func main() {
    password := []byte("password")
    salt := []byte("salt")

    // func pbkdf2.Key(password []byte, salt []byte, iter int, keyLen int, h func() hash.Hash) []byte
    key := pbkdf2.Key(password, salt, 1000, 32, sha256.New)
}

今回はベースとなるハッシュ関数 h を SHA-256、ストレッチング回数 iter を 1000 とした2。 鍵長 keyLen を 32 bytes にしていること、また salt については後述する。

AES-256-GCM

次に AES-256-GCM について。このままだと難しいので AES と 256 と GCM に分解する。

AES

共通鍵暗号のブロック暗号アルゴリズム。このままだと難しいので共通鍵暗号とブロック暗号に分解する。

共通鍵暗号3

暗号化と復号に同一の鍵を用いる暗号方式のこと。 暗号化に用いた鍵があればデータを復号できる。

ブロック暗号4

固定長のデータ (ブロックと呼ばれる) を単位に暗号化する暗号のこと。

256

AES で暗号化する際に用いる鍵のサイズ (ビット数)。 256 の他に 128 と 192 がある。 長ければ長いほど計算量が増えて安全になる。

GCM

ブロック暗号では固定長のデータしか扱えないため、ブロック長より長いデータを暗号化するためにはブロック暗号を繰り返して適用する必要がある。 この繰り返しの方法を暗号利用モードといって、GCM はそのひとつ。

ここで、AES-256-GCM を扱う実装についてまとめる。

package main

import (
    "crypto/aes"
    "crypto/cipher"
)

func main() {
    var key [32]byte

    // 鍵として 256 bits (= 32 bytes) のバイトスライスを引数に渡すと AES-256 が選択される
    block, err := aes.NewCipher(key[:])
    if err != nil {
        panic(err)
    }

    aead, err := cipher.NewGCM(block)
    if err != nil {
        panic(err)
    }
}

コメントにも書いたが aes.NewCipher() に 32 bytes の鍵を渡すことで AES-256 が選択される。 PBKDF2 で導出する鍵長を 32 bytes にしていたのはこのためだ。

また cipher.NewGCM() の戻り値に aead と名前をつけているが、次はこれについて説明する。

AEAD

暗号化に加えて、認証を同時に行う暗号利用モードを AEAD という。 認証によって、暗号文が改ざんされていないこと、また正規に生成されたものであることが保証される5

GCM は認証付きの暗号利用モードのため、cipher.NewGCM()cipher.AEAD を返す。 この cipher.AEAD を用いた暗号化の実装は以下になる。

package main

import (
    "crypto/cipher"
    "crypto/rand"
    "io"
)

func main() {
    var aead cipher.AEAD

    plaintext := []byte("plaintext")

    iv := make([]byte, aead.NonceSize())
    if _, err := io.ReadFull(rand.Reader, iv); err != nil {
        panic(err)
    }

    encryptedData := aead.Seal(iv, iv, plaintext, nil)
}

今度は iv という名前がいっぱい出てきた。

IV

暗号文は暗号アルゴリズムと平文と鍵の組み合わせで決定されると前述した。 これは組み合わせが一致していれば暗号文は常に同じになるということであり、暗号初期化ベクトルは役に立つのか?|徳丸 浩|note で紹介されているような危険性がある。 そこで、IV (Initial Vector) と呼ばれる一意な値を付加することで6、組み合わせが一致していても異なる暗号文が出力されるようにする7

復号には暗号化のときに付加した IV が必要になるのだが、それを踏まえて先ほどのコードの

   encryptedData := aead.Seal(iv, iv, plaintext, nil)

の部分を詳しくみていく。まずは Seal()シグネチャを以下に示す8

// nonce はここでは IV を指す。IV は一意である必要があるため、使い捨てとなる。このような値を nonce (number used once) と呼ぶ。
// 戻り値は dst に暗号文を append したバイトスライスになる。
func (cipher.AEAD).Seal(dst []byte, nonce []byte, plaintext []byte, additionalData []byte) []byte

ここで引数 dstiv を渡すことによって、戻り値が IV と暗号文を連結したバイトスライスになる。 IV のサイズは aead.NonceSize() で取得可能なため、このバイトスライスを暗号データとして保存しておくことで復号時に以下で IV を取得できる。

package main

func main() {
    var encryptedData []byte

    iv, ciphertext := encryptedData[:aead.NonceSize()], encryptedData[aead.NonceSize():]
}

また、この IV と暗号文をもとに復号するコードは以下になる。

package main

import "crypto/cipher"

func main() {
    var aead cipher.AEAD
    var iv, ciphertext []byte

    plaintext, err := aead.Open(nil, iv, ciphertext, nil)
    if err != nil {
        panic(err)
    }
}

まとめ

PBKDF2 による鍵の導出と AES-256-GCM による暗号化を Go で実装した。

package main

import (
    "crypto/aes"
    "crypto/cipher"
    "crypto/rand"
    "crypto/sha256"
    "fmt"
    "io"

    "golang.org/x/crypto/pbkdf2"
)

func main() {
    password := []byte("password")
    salt := []byte("salt")

    key := pbkdf2.Key(password, salt, 1000, 32, sha256.New)

    block, err := aes.NewCipher(key)
    if err != nil {
        panic(err)
    }

    aead, err := cipher.NewGCM(block)
    if err != nil {
        panic(err)
    }

    plaintext := []byte("plaintext")

    iv := make([]byte, aead.NonceSize())
    if _, err := io.ReadFull(rand.Reader, iv); err != nil {
        panic(err)
    }

    // encrypt
    encryptedData := aead.Seal(iv, iv, plaintext, nil)

    // decrypt
    iv2, ciphertext := encryptedData[:aead.NonceSize()], encryptedData[aead.NonceSize():]
    plaintext2, err := aead.Open(nil, iv2, ciphertext, nil)
    if err != nil {
        panic(err)
    }

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

  1. Rails を参考にした

  2. Rails を参考にした

  3. 他に、暗号化と復号に異なる鍵を用いる公開鍵暗号が存在する

  4. 他に、ビットやバイト単位で逐次暗号化するストリーム暗号が存在する

  5. 細工した暗号文を復号させることで情報を得る選択暗号文攻撃に対して安全になるらしい (よくわかっていない)

  6. AES-GCM の安全性は IV が重複しないことが前提となっている。例示したコードは乱数の生成回数が増えると衝突する可能性があり、本当は怖いAES-GCMの話 - ぼちぼち日記 では IV をカウンター値にするといった対策が挙げられていた。

  7. パスワードのハッシュ化においても、似たような目的で salt と呼ばれる一意な値を付加する。pbkdf2.Key() に渡していた salt がこれにあたる。

  8. 引数 additionalData に渡したデータは暗号文に含まれないが認証の対象にはなる。つまり復号時に同じデータを渡さないと復号に失敗するということだが、ユースケースがよくわかっていない。