Laravel の Cookie 暗号化の仕組みを理解する
Laravel の機能で Cookie が発行される場合、その値は基本的に暗号化されている。
元の値が変わっていなくても、暗号化後の値はリクエストする度に異なっている。このように変化する値をどのように復号化しているのか知るため、本記事ではこれらの仕組みについて Laravel のソースコードを読んで理解する。
環境
- PHP 8.2
- Laravel 10.20.0
前提
公式ドキュメントの Encryption のページの内容を前提知識とする。
AES 暗号化について
ドキュメントで述べられている通り、 Laravel では OpenSSL を使って暗号化を行っており、デフォルトでは暗号化方式として AES-256-CBC が使われる。後述する内容に関わるので、簡単にこの暗号化方式について説明する [1]。
AES (Advanced Encryption Standard) は共通鍵暗号アルゴリズムの1つであり、 AES-256-CBC では 256 ビットの共通鍵が用いられ、暗号利用モードに CBC が利用される。 AES ではデータを一定サイズのブロックに分割し、ブロック毎に暗号化するブロック暗号という方式を取っているが、暗号利用モードとはその連続するブロック列を暗号化する方式を指す。 CBC を含むいくつかの暗号利用モードは最初のブロックを暗号化するために初期化ベクトル (以降 IV と書く) というデータを必要とする。したがって IV の値を変えれば、元の平文が同一の内容であっても暗号文は異なるものになる。
より理解を深めるため、 Laravel で暗号化された Cookie の値を復号してみる。例えば冒頭のスクリーンショットに含まれるセッション ID の値は次のようになっている。
eyJpdiI6InR2TFRXZVUwOTUxRTF0SSt0V2dvRUE9PSIsInZhbHVlIjoiNVN3Z3RQbDJQUlE1Vk1FcVpxMXNQajhGMXg3dkc1dmRJL0o2ZTZIY1pLK25aYW5Nc21KazNlcXMzK2dyOEIxSHJLUlIwQ01QdXVyNmEzanEyVzNQZndkNk04RkYxL2xXbnVpalQyUTU5WWswS1o2d3hMR2pGcjFZQjhsTktBM2ciLCJtYWMiOiJhN2QyMTZlMTRjODliNTU0MTE0OTRiNDIyNDg0YmZmMjQ2Nzk0MDRhZmQwM2E1YjE2OTNiMDU3M2Q4ZTA5OTY1IiwidGFnIjoiIn0%3D
末尾の %3D
は =
が URL エンコーディングされた結果なので、元に戻す。
eyJpdiI6InR2TFRXZVUwOTUxRTF0SSt0V2dvRUE9PSIsInZhbHVlIjoiNVN3Z3RQbDJQUlE1Vk1FcVpxMXNQajhGMXg3dkc1dmRJL0o2ZTZIY1pLK25aYW5Nc21KazNlcXMzK2dyOEIxSHJLUlIwQ01QdXVyNmEzanEyVzNQZndkNk04RkYxL2xXbnVpalQyUTU5WWswS1o2d3hMR2pGcjFZQjhsTktBM2ciLCJtYWMiOiJhN2QyMTZlMTRjODliNTU0MTE0OTRiNDIyNDg0YmZmMjQ2Nzk0MDRhZmQwM2E1YjE2OTNiMDU3M2Q4ZTA5OTY1IiwidGFnIjoiIn0=
この文字列は Base64 形式でエンコードされた結果なので、デコードする。
{"iv":"tvLTWeU0951E1tI+tWgoEA==","value":"5SwgtPl2PRQ5VMEqZq1sPj8F1x7vG5vdI/J6e6HcZK+nZanMsmJk3eqs3+gr8B1HrKRR0CMPuur6a3jq2W3Pfwd6M8FF1/lWnuijT2Q59Yk0KZ6wxLGjFr1YB8lNKA3g","mac":"a7d216e14c89b55411494b422484bff24679404afd03a5b1693b0573d8e09965","tag":""}
JSON 形式になっており、暗号文の本体 (value
) と一緒に IV (iv
) や MAC (mac
) などが一緒に付与されていることが分かる [2]。これらの情報だけでは復号することはできず、復号するには秘密鍵が必要となる。 Laravel では環境変数 APP_KEY
の値が (Base64 エンコードされた) 秘密鍵として使用される。 Laravel アプリケーション内では、これらの値と PHP の openssl_decrypt
関数を使って次のように復号できる。
// Cookie から取得
$encryptedValue = "5SwgtPl2PRQ5VMEqZq1sPj8F1x7vG5vdI/J6e6HcZK+nZanMsmJk3eqs3+gr8B1HrKRR0CMPuur6a3jq2W3Pfwd6M8FF1/lWnuijT2Q59Yk0KZ6wxLGjFr1YB8lNKA3g";
$iv = base64_decode("tvLTWeU0951E1tI+tWgoEA==");
// 環境変数 APP_KEY から取得
$key = base64_decode(Str::after(Config('app.key'), 'base64:'));
echo openssl_decrypt($encryptedValue, "aes-256-cbc", $key, 0, $iv);
// "81147b8ce51ddd66c0acd5820849cd8d467b523b|dE8QeAihlukphwsyClRDUniyvFvHKGbBAK9wV8Yg"
逆に暗号化は openssl_encrypt
関数を使って次のように実現できる。
$value = "81147b8ce51ddd66c0acd5820849cd8d467b523b|dE8QeAihlukphwsyClRDUniyvFvHKGbBAK9wV8Yg";
echo openssl_encrypt($value, "aes-256-cbc", $key, 0, $iv);
冒頭で挙げた復号化に関する疑問に対しては、暗号化の結果を変化させる IV も Cookie 中に含まれており、それを復号時に利用するためと回答できる。
Laravel のソースコードを読む
それでは上記のような暗号化・復号化の処理がどのように行われているのか、 Laravel のソースコードを読んでいく。今回は公式ドキュメントに従って Laravel Sail の環境を構築した。
Cookie の復号化と暗号化
まず App\Http\Kernel
を見てみる。
<?php
namespace App\Http;
// ...(中略)...
class Kernel extends HttpKernel
{
// ...(中略)...
/**
* The application's route middleware groups.
*
* @var array<string, array<int, class-string|string>>
*/
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
// ...(中略)...
];
// ...(中略)...
}
web
グループに含まれている App\Http\Middleware\EncryptCookies
に注目する。このミドルウェア自体は暗号化の対象から除外する Cookie の key 名を指定するための便利クラスで、実際に Cookie の暗号化・復号化を担うのは継承元クラスの Illuminate\Cookie\Middleware\EncryptCookies
である。
<?php
namespace Illuminate\Cookie\Middleware;
// ...(中略)...
class EncryptCookies
{
/**
* The encrypter instance.
*
* @var \Illuminate\Contracts\Encryption\Encrypter
*/
protected $encrypter;
// ...(中略)...
/**
* Create a new CookieGuard instance.
*
* @param \Illuminate\Contracts\Encryption\Encrypter $encrypter
* @return void
*/
public function __construct(EncrypterContract $encrypter)
{
$this->encrypter = $encrypter;
}
// ...(中略)...
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return \Symfony\Component\HttpFoundation\Response
*/
public function handle($request, Closure $next)
{
return $this->encrypt($next($this->decrypt($request)));
}
// ...(中略)...
}
handle
メソッドで受け取ったリクエストを $next()
に引き渡す前に、 decrypt()
メソッドを通して Cookie の復号化を行っている。また $next()
の返り値に対して encrypt()
メソッドを通して Cookie の暗号化を行った上でレスポンスを返している。
EncryptCookies
自体には前述したような OpenSSL を利用した暗号化処理は実装されていない。 encrypt()
メソッドおよび decrypt()
メソッドの内部で \Illuminate\Contracts\Encryption\Encrypter
のメソッドが呼び出され、暗号化・復号化された値を受け取って利用するような実装になっている。
暗号化・復号化本体の処理
次に \Illuminate\Contracts\Encryption\Encrypter
について見ていく。このインターフェースを実装しているのは \Illuminate\Encryption\Encrypter
クラスであり、 Encrypter
クラスへのバインドは \Illuminate\Encryption\EncryptionServiceProvider
で定義されている。
<?php
namespace Illuminate\Encryption;
// ...(中略)...
class EncryptionServiceProvider extends ServiceProvider
{
/**
* Register the service provider.
*
* @return void
*/
public function register()
{
$this->registerEncrypter();
$this->registerSerializableClosureSecurityKey();
}
/**
* Register the encrypter.
*
* @return void
*/
protected function registerEncrypter()
{
$this->app->singleton('encrypter', function ($app) {
$config = $app->make('config')->get('app');
return new Encrypter($this->parseKey($config), $config['cipher']);
});
}
// ...(中略)...
}
register()
メソッド内で registerEncrypter()
メソッドが呼び出されている。このメソッド内で \Illuminate\Encryption\Encrypter
が encrypter
という名前でシングルトンとしてバインドされている。第1引数のところで $this->parseKey()
メソッドが呼び出され、この返り値が秘密鍵として利用される。
/**
* Parse the encryption key.
*
* @param array $config
* @return string
*/
protected function parseKey(array $config)
{
if (Str::startsWith($key = $this->key($config), $prefix = 'base64:')) {
$key = base64_decode(Str::after($key, $prefix));
}
return $key;
}
最初の if 文の $this->key($config)
では環境変数 APP_KEY
の値が (正確には、その値が設定された config の app.key
の値が) 取得される。通常、この値は Base64 エンコーディングされていることを示すために base64:Cf55GWdTSq2vsCndhjUDZ+GQXd+FSSEaYvthoanssYc=
のような形式になっているので、先頭の base64:
を取り除いて Base64 デコードした上で値を返す処理がある。
続いて \Illuminate\Encryption\Encrypter
クラスの中身を見てみる。まずは暗号化の処理を抜き出してみる。
<?php
namespace Illuminate\Encryption;
// ...(中略)...
class Encrypter implements EncrypterContract, StringEncrypter
{
// ...(中略)...
/**
* Encrypt the given value.
*
* @param mixed $value
* @param bool $serialize
* @return string
*
* @throws \Illuminate\Contracts\Encryption\EncryptException
*/
public function encrypt($value, $serialize = true)
{
$iv = random_bytes(openssl_cipher_iv_length(strtolower($this->cipher)));
$value = \openssl_encrypt(
$serialize ? serialize($value) : $value,
strtolower($this->cipher), $this->key, 0, $iv, $tag
);
if ($value === false) {
throw new EncryptException('Could not encrypt the data.');
}
$iv = base64_encode($iv);
$tag = base64_encode($tag ?? '');
$mac = self::$supportedCiphers[strtolower($this->cipher)]['aead']
? '' // For AEAD-algorithms, the tag / MAC is returned by openssl_encrypt...
: $this->hash($iv, $value);
$json = json_encode(compact('iv', 'value', 'mac', 'tag'), JSON_UNESCAPED_SLASHES);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new EncryptException('Could not encrypt the data.');
}
return base64_encode($json);
}
// ...(中略)...
}
最初に random_bytes()
で使用する暗号化方式に適した文字長の IV を生成している。リクエストの度に生成結果が変化するのはこの処理のためである。ここで生成した IV と秘密鍵を使って openssl_encrypt()
関数で暗号化を行っている。
その後 IV の Base64 エンコードや MAC の生成を行った後、 json_encode(compact('iv', 'value', 'mac', 'tag'), JSON_UNESCAPED_SLASHES);
により JSON 文字列を組み立て、最後に base64_encode()
で Base64 エンコードして値を返す。
次に復号化の処理を見ていく。
/**
* Decrypt the given value.
*
* @param string $payload
* @param bool $unserialize
* @return mixed
*
* @throws \Illuminate\Contracts\Encryption\DecryptException
*/
public function decrypt($payload, $unserialize = true)
{
$payload = $this->getJsonPayload($payload);
$iv = base64_decode($payload['iv']);
$this->ensureTagIsValid(
$tag = empty($payload['tag']) ? null : base64_decode($payload['tag'])
);
// Here we will decrypt the value. If we are able to successfully decrypt it
// we will then unserialize it and return it out to the caller. If we are
// unable to decrypt this value we will throw out an exception message.
$decrypted = \openssl_decrypt(
$payload['value'], strtolower($this->cipher), $this->key, 0, $iv, $tag ?? ''
);
if ($decrypted === false) {
throw new DecryptException('Could not decrypt the data.');
}
return $unserialize ? unserialize($decrypted) : $decrypted;
}
最初に $this->getJsonPayload($payload)
を呼び出してデコードを行っている [3]。そして Base64 デコードされた IV と秘密鍵を使って openssl_decrypt()
関数で復号化を行っている。
まとめ
サマリーすると次のようになる。
-
Illuminate\Cookie\Middleware\EncryptCookies
により、リクエストに含まれる Cookie の復号化、レスポンスに含める Cookie の暗号化が行われる。 -
\Illuminate\Encryption\Encrypter
により、実際の暗号化・復号化の処理が行われる。暗号化の結果に影響する IV は毎回ランダム生成されており、それを Cookie 中に含めることで復号化できるようになっている。
Discussion