RareJob Tech Blog

レアジョブのエンジニア・デザイナーによる技術ブログです

CloudFrontの署名付きURLを利用したコンテンツ配信

はじめまして、7月に入社しましたサービス開発チームの越です。

今年は初めて東京で年を越したので、せっかくなら東京の郷土料理を食べようと思い、人生で初めてどじょう鍋を食べました。

どじょう鍋(柳川鍋)では、どじょう特有の泥臭さをごぼうが取り除いているとのことでした。

開発者から泥臭い仕事を取り除いてくれる、どじょう鍋で言うごぼうのようなツールやサービスに私も日々お世話になっています。

今回は業務で使用したCloudFrontの署名付きURLによるコンテンツ配信について書こうと思います。

AWS CloudFrontのドキュメントを読んで自分が理解するときにつまずいたところをまとめてみました。

AWS CloudFrontの署名付きURLをこれからはじめて利用する方の助けになれば幸いです。

AWS CloudFrontとは

以下AWSのドキュメントより引用

Amazon CloudFront は、ユーザーへの静的および動的なウェブコンテンツ (.html、.css、.js、イメージファイルなど) の配信を高速化するウェブサービスです。CloudFront では、エッジロケーションというデータセンターの世界的ネットワークを経由してコンテンツを配信します。...

CloudFront自体の説明はAWSのドキュメントを読むのが確実なので、ここでは割愛しますが、軽く全体の概要だけ知りたい場合は以下のAWS Summit Tokyo 2014のセミナーがまとまっていてわかりやすかったです。

AWS Summit Tokyo 2014

セミナーでは以下の3点について話されていて、署名付きURL(Signed URL)を用いたコンテンツ配信についてCloudFrontの利用をするときの概要やTipsについて説明があり、CloudFrontについて最初に理解するときに参照しました。

  • Amazon CloudFrontとは
  • CloudFrontによるサイト高速化
  • CloudFrontによるセキュア配信

署名付きURL(Signed URL)

署名付きURL(Signed URL)とは

有効期限を指定したワンタイムのデジタル署名付きURLでコンテンツにアクセスできるようにする機能で、主にコンテンツを保護する必要がある場面で利用するものになります。

たとえば、特定の会員やコンテンツを購入された方に対して、ダウンロードを許可するような場面になります。

デジタル署名とは

自分の言葉で簡単に説明できないので、以下より説明を引用させていただきます。 https://wa3.i-3-i.info/word14396.html

デジタル署名とは

コンピュータの世界のハンコ

であり

主に他の人に送るファイルにくっつけるデータで「そのファイルは○○さんが作ったやつですよ~」と「そのファイルは悪い人に改ざんされていませんよ~」を証明するものです。

補足として、AWS CloudFrontの署名付きURLで使用するデジタル署名はRSA署名方式が利用されているので、有効期限の設定には気をつけて利用します。

RSA署名方式の場合、その安全性の基礎となっているのは素因数分解問題の困難性であり、計算量的に困難であった素因数分解ができる確率が高まるという意味で、署名が生成されてから時間が経つほど、安全性が低下します。

署名付きURLの作成

実際に署名付きURLをを利用するときには、まず既定ポリシーとカスタムポリシーのどちらを利用するか選択し、あとは選択したポリシーのフォーマットに合わせて署名を作成し、URLを組み立てるだけになります。

署名付きURLの既定ポリシーとカスタムポリシーの選択

まずはAWSのドキュメントにある比較表を引用して、違いを確認していきます。

説明 既定ポリシー カスタムポリシー
ポリシーステートメントを複数のファイル用に再利用できる。ポリシーステートメントを再利用するには、Resource オブジェクトでワイルドカード文字を使用する必要があります。詳細については、「カスタムポリシーを使用する署名付き URL のポリシーステートメントで指定する値」を参照してください。 いいえ                           はい                          
ユーザーがコンテンツへのアクセスを開始できる日時を指定できる。 いいえ はい
(オプション)
ユーザーがコンテンツにアクセスできなくなる日時を指定できる。 はい はい
コンテンツにアクセスできるユーザーの IP アドレスまたは IP アドレス範囲を指定できる。 いいえ はい
(オプション)
署名付きURLにポリシーのbase64エンコードされたバージョンが含まれているため、URL が長くなる。 いいえ はい

動画の方で説明があったのですが、カスタムポリシー(Custom Policy)は既定ポリシー(Canned Policy)を拡張したものとのことです。

既定ポリシー(Canned Policy)

既定ポリシーでは以下2点の設定を行い、署名を作成します。

  • Resource: コンテンツのURL(フルパス)
  • DateLessThan: URLの有効期限切れ日時

これらの項目は既定ポリシーのポリシーステートメントで設定可能な値がこの2つだからになります。

既定ポリシーのポリシーステートメントの例

{
    "Statement": [
        {
            "Resource": "http://xxxxxxxxxxx.cloudfront.net/resources/horizon.jpg?size=large&license=yes",
            "Condition": {
                "DateLessThan": {
                    "AWS:EpochTime": 1357034400
                }
            }
        }
    ]
}

カスタムポリシー(Custom Policy)

カスタムポリシーでは、既定ポリシーの2点に加えて、オプションで以下の2点が設定可能になります。

  • DateGreaterThan: URLの有効期限の開始日時
  • IpAddress: GETリクエストを実行するクライアントの IP アドレス

つまり、カスタムポリシーのポリシーステートメントで設定可能な値は既定ポリシーで指定可能なものと合わせて4つになります。

さらに、既定ポリシーで指定可能な項目に加えて、カスタムポリシーではURLのPATHに対してワイルドカードが指定できるという特徴があります。

そのため、既定ポリシーではコンテンツ1つに対して1つ署名を必ず付けなければいけないというルールがありますが、カスタムポリシーの方を使用すると、特定のパターンのURLに対してすべて同じ署名を利用できるという特徴があります。

既定ポリシーと比較したときのカスタムポリシーの特徴として、比較表に記載があるようにURLが長くなるというのがあります。

既定ポリシーのポリシーステートメントの例

{
    "Statement": [
        {
            "Resource": "Resource": "http://xxxxxxxxxxx.cloudfront.net/resources/*",
            "Condition": {
                "IpAddress": {
                    "AWS:SourceIp": "192.0.2.10/32"
                },
                "DateGreaterThan": {
                    "AWS:EpochTime": 1357034400
                },
                "DateLessThan": {
                    "AWS:EpochTime": 1357120800
                }
            }
        }
    ]
}

既定ポリシーとカスタムポリシーの選択

選択の基準としては、以下の3点のいずれかの用途がある場合にはカスタムポリシーを利用し、どれも利用しないのであれば既定ポリシーを選択することになると思います。

  • URLの有効期限の開始日時を指定したい
  • GETリクエストを実行するクライアントの IP アドレスを制限したい
  • URLのPATHにワイルドカードを使用して、複数のリソースに対して1つの署名でアクセスしたい

署名付き URL の作成

キーペアの作成

事前準備として、署名を生成する際に必要になる秘密鍵と、CloudFrontに登録する公開鍵を作成します。

AWSのドキュメントを参考に作成していきます。

以下のコマンドで、秘密鍵を生成し、private_key.pem という名前のファイルに保存します。

openssl genrsa -out private_key.pem 2048

以下のコマンドで、上記のコマンドで生成したprivate_key.pemという名前のファイルから公開鍵を抽出します。

openssl rsa -pubout -in private_key.pem -out public_key.pem

パブリックキーをCloudFront にアップロード

パブリックキーを CloudFront にアップロードするには」を参考に、公開鍵をCloudFrontにアップロードします。

このとき生成されるパブリックキーIDをメモしておきます。 このIDは、このあと署名付きURLを作成するときに、Key-Pair-Idフィールドの値として使用します。

CloudFrontの設定

プライベートコンテンツをCloudFrontの署名付きURLで供給するように設定するには、以下のタスクを実行します。

プライベートコンテンツ提供のためのタスクリスト

署名付き URLの作成(既定ポリシー)

awscliのreferenceを参考に署名付きURLを生成します。

https://awscli.amazonaws.com/v2/documentation/api/latest/reference/cloudfront/sign.html

aws cloudfront sign \
    --url https://xxxxxxx.cloudfront.net/private-content/private-file.html \
    --key-pair-id XXXXXXXXXXX \
    --private-key file:///.../private_key.pem \
    --date-less-than 2020-11-18T19:30:00

生成されたURLのクエリパラメーターに以下の3つが設定されているので、既定ポリシーで作成されているのがわかります。 - Expires - Signature - Key-Pair-Id

https://xxxxxxx.cloudfront.net/private-content/private-file.html?Expires=1605727800&Signature=XXXX...&Key-Pair-Id=XXXXXXXXXXX

署名付きURLの作成(カスタムポリシー)

コマンドのオプションで有効期限の開始日を指定すると、URLにポリシーのbase64エンコードされたバージョンが含まれているので、カスタムポリシーで作成されているのがわかります。

aws cloudfront sign \
    --url https://xxxxxxx.cloudfront.net/private-content/* \
    --key-pair-id XXXXXXXXXXX \
    --private-key file:///Users/.../private_key.pem \
    --date-greater-than 2020-11-1T19:30:00 \
    --date-less-than 2020-11-18T19:30:00

URLのパスにワイルドカードを指定したので、生成された署名を使用して、https://xxxxxxx.cloudfront.net/private-content/*のパターンにマッチする複数のリソースに対してアクセスできます。

https://xxxxxxx.cloudfront.net/private-content/*?Policy=xxxx...&Expires=1605727800&Signature=XXXX...&Key-Pair-Id=XXXXXXXXXXX

確認と理解のために署名の有効性を検証してみる

生成されたURLでコンテンツにアクセスできるかどうか確認したら終わりですが、URLのクエリパラメーターにExpiresとKey-Pair-Idがあり、私は最初なぜこれが必要なのかわからなかったです。

CloudFront はパブリックキーを使用して署名を検証し、URLが改ざんされていないことを確認します。署名が無効である場合、リクエストは拒否されます。

ExpiresとKey-Pair-Idが署名の検証に使われていると分かると疑問が解決したので、署名付きURLを検証を実装してみます。

既定ポリシーで作成した署名付きURLを検証

https://xxxxxxx.cloudfront.net/private-content/private-file.html?Expires=1605727800&Signature=MOEBCVLfRO5CVOglBi9t-dUqI4RZpc5tqOZRwZceD5AHMLTTNFPP2WpXqw~UtpmkB1FF-t8wgiCzIQ~Qvd8hbOIffyb9Dh1I1saxN7HVpoLh99GwwV~LWZdGM3GdO3H6f~IqxLukTqnQf2F5xKrkD-g62Lyc3ShHRnIBOeug7seJpL3~hmFak2mUAa1NaFX2cw-XoZcXYqbi6RbK7hgxliXiLGcTa-D6FO3~r3qxohy5KxLR4A-fW-1gnRblaXUwFVp8eLG9Pvly5D5cH1NQMqgR9CAclJSYNM1ov-wUXQiwbkHMqW1m90KGxdQWluZLfdEHnrFwAUk40EOYfoUHMg__&Key-Pair-Id=XXXXXXXXXXX

既定ポリシーで作成した上記の署名付きURLを検証してみます。

署名のデコードや検証部分を実装するときに、署名生成のサンプルコードを参考にしながら実装しました。

<?php

// 公開鍵のファイルのPATH
$publicKeyFilename = './public_key.pem';
// プレミアムコンテンツのURL
$resource = "https://xxxxxxx.cloudfront.net/private-content/private-file.html";
// 有効期限(EpochTime)
$expiration = 1605727800;
// 署名(URLセーフなbase64でエンコードされてる)
$encoded_signature = "MOEBCVLfRO5CVOglBi9t-dUqI4RZpc5tqOZRwZceD5AHMLTTNFPP2WpXqw~UtpmkB1FF-t8wgiCzIQ~Qvd8hbOIffyb9Dh1I1saxN7HVpoLh99GwwV~LWZdGM3GdO3H6f~IqxLukTqnQf2F5xKrkD-g62Lyc3ShHRnIBOeug7seJpL3~hmFak2mUAa1NaFX2cw-XoZcXYqbi6RbK7hgxliXiLGcTa-D6FO3~r3qxohy5KxLR4A-fW-1gnRblaXUwFVp8eLG9Pvly5D5cH1NQMqgR9CAclJSYNM1ov-wUXQiwbkHMqW1m90KGxdQWluZLfdEHnrFwAUk40EOYfoUHMg__";

function verify($policy, $signature, $publicKeyFilename) {
    $fp = fopen($publicKeyFilename, "r");
    $publicKey = fread($fp, 8192);
    fclose($fp);
    $publicKeyId = openssl_get_publickey($publicKey);

    return openssl_verify($policy, $signature, $publicKeyId);
}

function decode($policy) {
    return base64_decode(strtr($policy, '-_~', '+=/'));
}

function createCannedPolicy($resource, $expiration)
{
    return json_encode([
        'Statement' => [
            [
                'Resource' => $resource,
                'Condition' => [
                    'DateLessThan' => ['AWS:EpochTime' => $expiration],
                ],
            ],
        ],
    ], JSON_UNESCAPED_SLASHES);
}

$signature = decode($encoded_signature);
$policy = createCannedPolicy($resource, $expiration);

$result = verify($policy, $signature, $publicKeyFilename);

if ($result == 1) {
    echo "正しいです";
} elseif ($ok == 0) {
    echo "正しくありません";
} else {
    echo "署名を確認する際にエラーが発生しました";
}
echo PHP_EOL;

公開鍵

手元で確認するので、コードでは秘密鍵から抽出した公開鍵のファイルのpathを使用しています。

AWS上では公開鍵はKey-Pair-Idと紐付いています。

<?php

$publicKeyFilename = './public_key.pem';

URLのクエリパラメータから取得した署名のデコード

URLのクエリパラメータから取得した署名はbase64エンコードされ、一部URLで無効な文字が置き換えられているので、以下の処理でデコードします。

<?php

function decode($policy) {
    return base64_decode(strtr($policy, '-_~', '+=/'));
}

署名を作成するときに使用したポリシーステートメントJSONを生成

既定ポリシーのポリシーステートメントjsonの生成に必要なものは、コンテンツのURLと有効期限の2つでしたので、以下の処理でjsonの文字列を生成します。

<?php

function createCannedPolicy($resource, $expiration)
{
    return json_encode([
        'Statement' => [
            [
                'Resource' => $resource,
                'Condition' => [
                    'DateLessThan' => ['AWS:EpochTime' => $expiration],
                ],
            ],
        ],
    ], JSON_UNESCAPED_SLASHES);
}

既定ポリシーを使用する署名付きURLの作成

署名の検証

PHP を使用したURL署名のサンプルコードでは、openssl_signで署名が作成されていたので、 phpopenssl_verifyで署名の検証を行います。

openssl_verifyでは以下のようなロジックで検証が行われていると思います。

  1. デコードした署名($signature)を、公開鍵を使って復号し、ハッシュ値を取得
  2. 既定ポリシーのポリシーステートメントjsonの文字列($policy)をハッシュ化して、ハッシュ値を生成
  3. 公開鍵で復号したハッシュ値jsonから生成したハッシュ値を比較し、完全に一致することを確認
<?php

function verify($policy, $signature, $publicKeyFilename) {
    $fp = fopen($publicKeyFilename, "r");
    $publicKey = fread($fp, 8192);
    fclose($fp);
    $publicKeyId = openssl_get_publickey($publicKey);

    return openssl_verify($policy, $signature, $publicKeyId);
}

デジタル署名の検証

署名の検証結果では以下のようなことが確認できます。

デジタル署名の検証が成功すると、以下のことが確認できます。
(1) メッセージが改ざんされていないこと
(2) メッセージは、検証に使用した公開鍵と対になる秘密鍵によって署名されたこと

ダイジェストが一致せずに検証が失敗すると、以下のいずれかの事象が発生したことが確認できます。
(1) メッセージが改ざんされたこと
(2) デジタル署名が改ざんされたこと

なお、メッセージとデジタル署名のどちらが改ざんされたかまでは分かりません。

https://www.ipa.go.jp/security/pki/024.html

1点注意点としては、有効期限が改ざんされていないことは検証できますが、有効期限切れかどうかは署名の検証では行っていないので、有効期限切れかどうかの判定については、署名の検証とは別で行います。

有効期限切れかどうかの判定を行うために、クエリパラメータにはExpiresとして有効期限を渡す必要があるのだと理解しました。

まとめ

CloudFrontの署名付きURLの手軽さと便利さがすごい!

参考