🔑

Laravel の Cookie 暗号化の仕組みを理解する

2023/09/23に公開

Laravel の機能で Cookie が発行される場合、その値は基本的に暗号化されている。

元の値が変わっていなくても、暗号化後の値はリクエストする度に異なっている。このように変化する値をどのように復号化しているのか知るため、本記事ではこれらの仕組みについて Laravel のソースコードを読んで理解する。

環境

  • PHP 8.2
  • Laravel 10.20.0

前提

公式ドキュメントの Encryption のページの内容を前提知識とする。

https://laravel.com/docs/10.x/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 の環境を構築した。

まず 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\Encrypterencrypter という名前でシングルトンとしてバインドされている。第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 中に含めることで復号化できるようになっている。
脚注
  1. このセクションの説明は主にとほほの暗号化入門の内容を参考にしている。 ↩︎

  2. 暗号化された Cookie の値が eyJpdiI6Ig... のように始まる理由について以前から気になっていたが、平文が {"iv":" のように始まるからということがここで分かった。 ↩︎

  3. getJsonPayload() メソッド内ではデコードに加えてと MAC の検証も行われる。 ↩︎

Discussion