🎛️

Go言語でLaravelの暗号化方式をエミュレートする

2024/07/03に公開

記事の概要

弊社ではe-dashと呼ばれるWebアプリケーションの開発を行っていますが、PHP(Laravel)で書かれたバックエンドをGo言語でフルリプレイスするプロジェクトを達成しました。

その過程でLaravelのencrypterを用いて、共通鍵で暗号化(AESアルゴリズムのCBCモード)されたデータをGo言語でも扱えるようにする必要性が発生しました。

Go言語で同様の暗号化・復号化ができる仕組みを実装したため、この経験を記事にまとめてみました。

Laravelのencrypterでの暗号化方式

今回対象とする暗号化方式は、OpenSSLを用いたAES-256-CBCによる共通鍵暗号です。AESや共通鍵暗号に関する解説は本記事ではしませんので、理解を深めたい方はこちらの記事などを参考にすると良いかと思います。

共通鍵の形式

Laravelでは以下の形式の共通鍵がAPP_KEYという環境変数として格納されるようです。

base64:n6ZC6x3t9ym3eDtfgVU+MwRGRQqo1CkF5V1XbSm1DPo=

base64というプレフィックスが付与されていますが、これはLaravelの独自の仕様です。こちらのプレフィックスを除いた部分が一般的なAESの鍵となります。

AESの暗号化方式で鍵長は、128,192,256ビットから選択することができますが、今回はAES-256なので256ビットの鍵長になります。これはLaravelに限った仕様ではないため、他の言語やコマンドでも生成可能かと思います。
Laravelでは通常phpのコマンドを使ってこちらの値を生成するようです。

暗号化された値

Laravelのencrypterを用いてなにかしらのデータの暗号化を行うと以下のような値が得られます。

eyJpdiI6InlXV1ZaME9YOUFyN2haemxLR3Y0S0E9PSIsInZhbHVlIjoiSEJ5WlBRaU82cGEzblA0UWhSckc0c0l4c0NmNWI0R1JteG4xNFVnOExCcm9uOVpTb0Fpd2VyUWFmV2xoejU1TSIsIm1hYyI6IjU1MjAwMWE1YzNkODliMDZjN2M0NGE5MjYyNDdjNTM4MjM5ZDUyMWIwNzY5ZWUxYmVmYTZjY2U5NmY1ZmQ4MmIifQ==

これはbase64でエンコードされています。デコードすると以下のようなJson形式の値が得られます。

{
  "iv":"yWWVZ0OX9Ar7hZzlKGv4KA==",
  "value":"HByZPQiO6pa3nP4QhRrG4sIxsCf5b4GRmxn14Ug8LBron9ZSoAiwerQafWlhz55M,
  "mac":"552001a5c3d89b06c7c44a926247c538239d521b0769ee1befa6cce96f5fd82b"
}

それぞれの要素を簡単に解説すると以下の通りです。

  • iv : 初期ベクトル(initialization vector)。ランダムなバイト列(AESのブロックサイズである128ビット長)
  • value : 対象データを共通鍵を用いて暗号化した値
  • mac : ivとvalue連結のハッシュ値(方式はsha256)

今回はAESのCBCモードであるため、ランダムなバイト配列であるivが含まれています(※注釈:CBCモードとIVに関して)。ivとvalueをDBに保存すれば十分な気もしますが、Laravelのencrypterでは、ivとvalueの連結のハッシュ値(mac)も同時に管理しているようです。

//注釈:CBCモードとIVに関して
AESのCBCモードでは、平文を128ビットの複数ブロックに分割し、暗号化の過程で前後のブロックの排他論理和を計算します。ただし、最初のブロックに関しては前のブロックが存在しないため、iv(初期ベクトル)を用いて排他論理和を計算します。このivをランダムなバイト列にすることにより、暗号化された値を毎回変化させることができるようになります。

Go言語で実装する必要はあるのか?

完全に新規のプロダクトであれば実装する必要性はないのですが、今回は既存プロダクトのリプレイスプロジェクトです。Laravelで作成されたデータをすべて引き継いでGo言語でも扱えるようにする必要がありました。つまり、前項のivvaluemacが含まれているJSONデータがDBに保存されていたため、リプレイス後のバックエンドでもLaravelのencrypterの暗号化・復号化を再現する必要がありました。

Go言語での暗号化の実装

中身が分かれば実装はそこまで難しくないでしょう。やることは以下です。

  1. ランダムなバイト配列の生成(iv)
  2. 共通鍵を用いて暗号化対象を暗号化(value)
  3. 1と2の連結のSha256のハッシュ値を生成(mac)
  4. 構造体をjsonにMarshalし、base64にエンコードして返却

ランダムなバイト配列の生成(iv)

//aesのブロックサイズ128ビットのランダムなバイト列を生成
iv := make([]byte, aes.BlockSize)
if _, err := rand.Read(iv); err != nil {
  return "", err
}

共通鍵を用いて暗号化対象を暗号化(value)

//commonKey(共通鍵)・data(暗号化対象)は引数として関数に渡される想定
block, err := aes.NewCipher(commonKey) 
if err != nil {
  return "", err
}
ciphertext := make([]byte, len(data))
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(ciphertext, []byte(data))

連結のSha256のハッシュ値を生成(mac)

mac := hmac.New(sha256.New, commonKey)
mac.Write([]byte(payload.Iv + payload.Value))

構造体をjsonにMarshalし、base64にエンコードして返却

//json変換用のstructを用意
type Payload struct {
  Iv    string `json:"iv"`
  Value string `json:"value"`
  Mac   string `json:"mac"`
}

//関数の最後に以下の処理を用意
payload := Payload{
  Iv:    base64.StdEncoding.EncodeToString(iv),
  Value: base64.StdEncoding.EncodeToString(ciphertext),
  Mac: hex.EncodeToString(mac.Sum(nil)),
}
jsonData, err := json.Marshal(payload)
if err != nil {
  return "", err
}
retValue := base64.StdEncoding.EncodeToString(jsonData) //これをreturnする

暗号化の関数が完成?!

以下のような関数を実装できました。果たしてこれで完成でしょうか?

func EncryptAES256CBC(data, base64Key string) (string, error) {
	if data == "" {
		return "", nil
	}
	base64Key = strings.TrimPrefix(base64Key, "base64:")
	decodedKey, err := base64.StdEncoding.DecodeString(base64Key)
	if err != nil {
		return "", err
	}

	block, err := aes.NewCipher(decodedKey)
	if err != nil {
		return "", err
	}

	ciphertext := make([]byte, len(data))
	iv := make([]byte, aes.BlockSize)
	if _, err := rand.Read(iv); err != nil {
		return "", err
	}

	mode := cipher.NewCBCEncrypter(block, iv)
	mode.CryptBlocks(ciphertext, []byte(data))

	payload := Payload{
		Iv:    base64.StdEncoding.EncodeToString(iv),
		Value: base64.StdEncoding.EncodeToString(ciphertext),
	}

	mac := hmac.New(sha256.New, decodedKey)
	mac.Write([]byte(payload.Iv + payload.Value))
	payload.Mac = hex.EncodeToString(mac.Sum(nil))

	jsonData, err := json.Marshal(payload)
	if err != nil {
		return "", err
	}

	return base64.StdEncoding.EncodeToString(jsonData), nil
}

完成かと思いきやこれは正しく動作しません!!
AES暗号化はブロック暗号化方式で、128bit(16byte)毎に暗号化を行います。したがって入力データがブロックサイズの倍数でなければなりません。

Paddingの必要性

16byteの倍数になるようにPaddingを行います。以下のコードを関数の真ん中あたり(aes.NewCipher(decodedKey)の手前)に追加します。

	padding := aes.BlockSize - len(data)%aes.BlockSize
	data += strings.Repeat(string(byte(padding)), padding)

関数の完成形

以下が完成形です。

func EncryptAES256CBC(data, base64Key string) (string, error) {
	if data == "" {
		return "", nil
	}
	base64Key = strings.TrimPrefix(base64Key, "base64:")
	decodedKey, err := base64.StdEncoding.DecodeString(base64Key)
	if err != nil {
		return "", err
	}

	// Add padding
	padding := aes.BlockSize - len(data)%aes.BlockSize
	data += strings.Repeat(string(byte(padding)), padding)

	block, err := aes.NewCipher(decodedKey)
	if err != nil {
		return "", err
	}

	ciphertext := make([]byte, len(data))
	iv := make([]byte, aes.BlockSize)
	if _, err := rand.Read(iv); err != nil {
		return "", err
	}

	mode := cipher.NewCBCEncrypter(block, iv)
	mode.CryptBlocks(ciphertext, []byte(data))

	payload := Payload{
		Iv:    base64.StdEncoding.EncodeToString(iv),
		Value: base64.StdEncoding.EncodeToString(ciphertext),
	}

	mac := hmac.New(sha256.New, decodedKey)
	mac.Write([]byte(payload.Iv + payload.Value))
	payload.Mac = hex.EncodeToString(mac.Sum(nil))

	jsonData, err := json.Marshal(payload)
	if err != nil {
		return "", err
	}

	return base64.StdEncoding.EncodeToString(jsonData), nil
}

まとめ

本記事ではLaravelのencrypterの暗号化をGo言語で実装する方法を解説しました。本記事では復号化の方法は解説しませんでしたが、暗号化と逆の手順を踏むことで復号化も同様に実装することができるかと思います(ただし、macの整合性チェックは復号化のみ実装が必要だと思います。)

世の中にはPHP->Golangにリプレイスしているプロジェクトが一定数あるのではないかと思いますので、そのようなプロジェクトに携わっている方に少しでも参考にしていただければ幸いです。

採用情報

e-dashエンジニアチームは現在一緒にはたらく仲間を募集中です!
同じ夢について語り合える仲間と一緒に、環境問題を解決するプロダクトを作りませんか?

Discussion