ユーザー毎に視聴の可否が異なる動画ファイルをCloudFrontで配信する方法の考察

# はじめに

AWSインフラ的には「有料会員はすべての動画を見ることができて、なおかつ動画が CDN にキャッシュされているウェブサイトを作る」ことは比較的簡単に実現できる。
具体的には、

- CloudFront の署名付き Cookie を用いて、ログイン時に特定パス以下 (例: `/private/*` ) を閲覧可能にするための署名付きCookieを埋め込み、以降のアクセスで利用する
- [ユーザーがログインした状態の JWT トークンを持ち、リクエスト時にCloudFrontの Lambda@Edge や CloudFunction でJWTトークンを検証する](https://aws.amazon.com/jp/blogs/networking-and-content-delivery/authorizationedge-how-to-use-lambdaedge-and-json-web-tokens-to-enhance-web-application-security/)

などの方法がある。

一方、例えば「会員登録は無料だが、個々の動画を都度購入する」タイプのシステムを作ろうとすると、「この会員はこの動画を見てよいか？」という部分を判断する必要がある。

ここでは、動画ファイルは CloudFront にキャッシュされ効率的に配信したいが、その一方で、会員別にファイルダウンロードの可否をシステムレベルで判断する実装に関してを考察する。


# 基本方針

今回のシステム要件として、以下を考える。

- 配信動画ファイルはLIVEではなく、VOD配信の固定動画ファイルを配信すること
- メジャーなブラウザ・OSでブラウザ上で動画を閲覧できる動画フォーマットであること
- (当たり前だが) URLを外部にコピーした場合、そのリンクを踏んでも権限のないユーザーが動画を見ることはできないこと
- 動画ファイルを CloudFront で極力キャッシュし、オリジン側への負荷を減らすこと
- ユーザーが抜け道を使って動画ファイルをダウンロードすることは対処しない
    - いわゆる商用の DRM プラットフォームを使っての動画の保護は行わない
    - が、可能な限り対処はしたい

# TL; DR

- 暗号化されたHLS形式の動画を配信し、鍵ファイルの配信パスだけを Lambda@Edge でユーザー認証することでシステム全体の負荷を減らし、CDNキャッシュを利用しつつも、許可のある人のみが動画を再生できるウェブサイトを作成できる。
- 基本的な仕組みが理解できれば、それを実現するための手段はプロジェクトごとにカスタマイズして使うこともできる（ただ１つの方法のみが解決策というわけではない）


# CloudFront によるアクセス制御の技術比較

CloudFront のアクセス制御には **署名付きCookie** や **一時URL (Presigned URL)** を使うことが有名であるが、今回の場合には以下のようにうまく使えない。 一方で、**Lambda@Edge** を使う方法には可能性がある。

## 署名付き Cookie 

以下の理由により、CloudFront の署名付き Cookie を使うことはあきらめた。

- CloudFront による署名付きCookie (カスタムポリシー) は便利だが、ポリシーとして **１つのパスしか保持できない** 。
    - なので、`/private/*` 以下のような指定はできるが、`/private/001/*` AND `/private/002/*` みたいな方式は無理
- CloudFront による署名付きCookie (パス指定で識別) を使うことはできるが、Cookie であることが問題
    - 最大で4KB程度の容量しかない
    - Cookie で保持できる Key/Value の組がブラウザによって異なる (最小が20以上が保証されているが、50個程度のブラウザもある)
    - さらに、数を再現なく増やしていくとブラウザ独自のアルゴリズムに基づいて、Cookie が削除されることもある (セッションクッキーが消えてログインできなくなることも...)

## 一時 URL (Presigned URL)

これは URL が知られてしまうと、そのURLを使ってダウンロードができてしまう。 なので、URLのコピペに非常に弱いという欠点がある。
有効期限を極力短くすることで暫定的に対処は可能だが、例えば分割ダウンロードをしたい場合や、HLSフォーマットのように動画が分割されたファイルになっている場合は相性が悪い。

## Lambda@Edge によるビュワーリクエスト

「ユーザーからのアクセスがあったタイミング」で「このURIをユーザーに開示しても良いか」をLambda@Edge のプログラムによって判断することができる。 特にビューワーリクエストは「CDNに到達する直前」の通信に対して起動し、状況が悪ければ CDN に通信を届かせることなくエラーレスポンスを返すことができる。 言い換えると、CDNにデータがキャッシュされていても、そのキャッシュにアクセスさせないようなガード判定を行うことができる。 

また、Lambda@Edge ではかなり柔軟にプログラムを書いて動かせるだけでなく、**AWSのリソースにアクセスすることすら可能** である。 そのため、例えばマスターデータを DynamoDB に置いておき、その内容を読み込んで処理を分岐させることも可能になっている。


## まとめ

- この要件を解決するため、動画の保護は Lambda@Edge のビュワーリクエストによって行う
- ビュワーリクエスト内でユーザー個別のDB (DynamoDB) にアクセスして、認証を行う

という方針で実装を考えていく。
ただし、この方法では以下のように **「アクセス集中」「レイテンシー」の問題があることを認識しておかなくてはいけない。**

- **アクセス集中:**（何も工夫しないと）アクセスの都度 DynamoDB にアクセスが発生するため大量アクセスがあるとDynamoDBのスロットルが限界を迎えて、ユーザーにコンテンツを提供できない場合があることを考慮する必要がある。 特に動画が分割されたファイル形式 (HLS形式など) の場合、１つの動画を再生する度にアクセスは分割ファイル数分発生することに注意
- **レイテンシー**: CloudFront は全世界対象で、Lambda@Edge のコードは各エッジロケーションにコピーされて実行される。 そのため、Lambda@Edge の実行場所は全世界であり、例えば ap-northeast-1 リージョンのテーブルを１つだけ用意した場合、アメリカからのアクセスでは「アメリカのエッジローケーション⇒日本のDynamoDBへのアクセス」が発生し、CloudFront によるキャッシュが上手く活かされない低速のレスポンスとなることが想定される。 そのため、必要に応じて DynamoDB のテーブルを複数リージョンに用意するなどのレイテンシーを小さく抑える工夫をする必要がある。


# 配信動画の形式

動画の暗号化強度や DRM の考え方などは以下が参考になった。

https://dev.classmethod.jp/articles/hls-aes-with-aws-elemental-medialive/

また、AWS MediaConvert で **暗号化された HLS 形式の動画ファイル**  を作成できることが分かった。 これは、先の問題と組み合わせても、非常に都合のいい形式なので、これを採用することとした。 都合がいい理由は以下の通り。

- 動画ごとに異なる鍵を採用し **この鍵の取得リクエストのみ認証をかけるようにする** 
- 暗号化HLS形式のファイルは `.m3u8` ファイル内に復号化用の鍵へのリンクを保持している
- HLS形式の動画を再生する場合、複数回のダウンロードは発生するが、**鍵へのアクセスは1度のみ** に限定されるので、アクセス回数が少なくなり、レイテンシーの影響も小さくなる
- 例え動画の `.ts` ファイル (HLS形式で分割された動画の実体) へアクセスできても、鍵がなければ正常に動画を再生できないため、個別の `.m3u8` や `.ts` ファイルをURLベタ打ちでダウンロードされても問題ない
  - 実際はこの部分を署名付きCookieで簡易保護するとなおよい

# システム構成図

以上を考えて、システム全体の構成をまとめると以下のようになる。 このシステムでは S3 バケット(Video Bucket) は Web Hosting として **公開せず**、すべて CloudFront 経由で公開することを想定している。

![hls-auth-archivecture.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/502582/3cd5c4e5-525a-aab5-7276-27966a0c49e8.png)

Application 部分は管理システムであり、ここでは動画のアップロードや鍵・ユーザーの管理、MediaConvertのジョブ生成などを行うことを想定している。


また、この図ではあくまで概念のみを示している。
実際には S3 へのアップロードをイベントトリガーとした Lambda によって MediaConvert によるジョブの実行を開始しても良いし、`/keys/*` 以下を Lambda@Edge + DynamoDB で処理しているが、このパスだけをオリジンサーバーへと送って、その「鍵を取得する部分のみ、アプリケーションで制御する」としても良い。 そのあたりは個別のプロジェクトにあった形で処理してもらえばよいと思う。

そのため、この構成図を元にしつつ、自分たちのプロジェクトに都合の良いようにパーツを構成していくことができる。

# Media Convert の設定

Media Convert ではS3に動画をアップロードしておき、そのパスを指定することで動画形式の変換を行うことができる。 暗号化 HLS 形式に変換するジョブを作るときに悩んだ点についてを以下にメモする。

## Media Convert の変換にかかる料金

設定の中に `PRO` とラベルがあるものがあってそのあたりで「これ使ってどれぐらい料金に差が出るの…？」と思ったが、Media Convert には「Basic階層」と「Professional階層」があり、それぞれで変換にかかる料金が異なる。 しかし、これは歴史的経緯のようなもので「単純なものは安価でできる BASIC が後から追加された」と考えるぐらいで良さそう。

https://aws.amazon.com/jp/mediaconvert/pricing/

主に「出力動画の解像度」と「Basic/Pro」のどちらを使ったかで料金が決まる。 上記の価格には単位が抜けていて、単位は「毎分」である。 Pro階層 (※暗号化HLSはProの料金になる) で 30FPS / 4K 解像度の動画を出力した場合、1パスでの変換にかかる時間は **0.0136USD/分** となる (※記事執筆の2021年6月12日現在)。 もし、30秒の動画をこの価格帯で変換した場合、その変換にかかる料金は **0.0136 * 0.5 = 0.0068 USD** となる。 **ジョブにかかった時間でなく、動画の長さに依存する課金体系となっている** ことは覚えておくと良いだろう。

より詳細な料金体系を知るには以下が参考になった。 



https://dev.classmethod.jp/articles/aws-elemental-mediaconvert-introduces-basic-pricing-tier/

## HLS AES暗号化に使う鍵ファイルと設定

HLS で AES 暗号化を選ぶには、Management Console から 出力に Apple の HLS 形式を選び、DRM 暗号化で「暗号化方法: AES128」「キープロバイダーのタイプ: 静的キー」を選ぶ。

![SnapCrab_NoName_2021-6-12_17-36-4_No-00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/502582/d02f5bda-9cd1-16ff-7b92-5b77ad837cd5.png)

その後に「動画を暗号化するためのキー」を「静的キーの値」に入力する。 具体的には、暗号化に使う 128bit (16byte) を 16進数で表記した32文字。 極端な話、`00000000000000000000000000000000` でもOK。
「URL」には「鍵が記載されたファイルをダウンロードできるURL」を入力する。 ここに入力したURLは エンコード後の `.m3u8` ファイルにそのまま書き出される。 このファイルは **静的キーで入力した値がバイナリ形式で保持された16 bytesのファイル** でなければならない。

例えば、バイナリ形式の鍵ファイルは `openssl rand 16 > aes128.key` で生成可能であり、`xxd -p aes128.key` で16進数出力を得ることができる。

# CloudFront の Lambda@Edge 処理

## 必要な各種設定

前準備として、アクセス許可を判断する DynamoDB のテーブルを１つ作っておく。
ここでは東京 (ap-northeast-1) リージョンに `hls-aes-auth-test` テーブルを作って、以下のデータを登録した。 `ui` がテーブルのハッシュキー、`keyname` がテーブルのレンジキーである。

![SnapCrab_NoName_2021-6-12_17-52-48_No-00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/502582/96ff6d94-5be4-6f4f-e8f2-ca87ac1ce33c.png)

その後、CloudFront に `/keys/*` と `*` の2つの Behaviors 登録する。 これらのアクセスはすべて S3 Bucket オリジンに流すように設定している。
そして、`/keys/*` の Behavior に以下の関数による Lambda@Edge を実行するように指定する。

```python:hls-key-auth.py
#!/usr/bin/python
# -*- coding: utf-8 -*-

'''
リクエスト許可があるかを Lambda@Edge のレベルでチェックします。
'''

import boto3

TABLENAME = 'hls-aes-auth-test'
HASH_KEY = 'ui'
RANGE_KEY = 'keyname'

dynamodb = boto3.session.Session(
    region_name='ap-northeast-1').client('dynamodb')


def _error_response() -> dict:
    return {
        'status': '401',
        'statusDescription': 'Unauthorized',
        'body': 'Authentication Failed',
    }


def _parse_cookie(headers) -> dict:
    """ Cookie を dict 形式に parse して返します """
    if 'cookie' not in headers:
        return {}
    cookies = {}
    cookie_text = headers['cookie'][0]['value']
    for _cookie in cookie_text.split(';'):
        cookie = _cookie.strip()
        ts = cookie.split('=')
        if len(ts) < 2:
            continue
        cookies[ts[0].lower()] = ts[1]
    return cookies


def _has_item(uid: str, keyname: str):
    """ Dynamoに閲覧可能情報が保持されているかをチェック """
    if (not uid) or (not keyname):
        return False
    try:
        res = dynamodb.get_item(
            TableName=TABLENAME,
            Key={
                HASH_KEY: {'S': uid},
                RANGE_KEY: {'S': keyname}
            }
        )
        return 'Item' in res
    except Exception as err:
        return False


def lambda_handler(event, context):
    request = event['Records'][0]['cf']['request']
    headers = request['headers']

    # Cookie からログイン中のユーザーIDを取得
    cookies = _parse_cookie(headers)
    uid = cookies.get('uid')

    # パスからアクセスするキー情報を取得
    uri = request['uri']

    if not _has_item(uid, uri):
        return _error_response()

    return request
```

コードでは、Cookie から `uid` の項目を取得して、アクセスユーザーを特定する。
その後、`/keys/*` 以下にアクセスした URI が指定する鍵へのアクセス権限がテーブル上に存在しているかをチェックしている。 仮に権限がない場合は CDN に届かせることなく 401 レスポンスを返す。

## 動作確認

ここでは、[hls.js](https://github.com/video-dev/hls.js/) のサンプルを参考にして以下のような実装を行った。 MediaConvertによる動画は `/videos/0001/` 以下に出力している。

```html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>テスト</title>
</head>
<body>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<h1>テスト動画0001</h1>
<video id="video"></video>
<script>
  var video = document.getElementById('video');
  var videoSrc = '/videos/0001/underwater.m3u8';
  if (Hls.isSupported()) {
    var hls = new Hls();
    hls.loadSource(videoSrc);
    hls.attachMedia(video);
  }
  // HLS.js is not supported on platforms that do not have Media Source
  // Extensions (MSE) enabled.
  //
  // When the browser has built-in HLS support (check using `canPlayType`),
  // we can provide an HLS manifest (i.e. .m3u8 URL) directly to the video
  // element through the `src` property. This is using the built-in support
  // of the plain video element, without using HLS.js.
  //
  // Note: it would be more normal to wait on the 'canplay' event below however
  // on Safari (where you are most likely to find built-in HLS support) the
  // video.src URL must be on the user-driven white-list before a 'canplay'
  // event will be emitted; the last video event that can be reliably
  // listened-for when the URL is not on the white-list is 'loadedmetadata'.
  else if (video.canPlayType('application/vnd.apple.mpegurl')) {
    video.src = videoSrc;
  }
</script>
</body>
</html>
```

Cookie の値が全くない状態でこのページにアクセスすると、鍵ファイル (0001.key) のダウンロードに失敗する。結果として、動画は再生できない。

![SnapCrab_NoName_2021-6-12_17-59-48_No-00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/502582/c5e8c761-e00f-39e1-6b09-0875b7c757d2.png)

一方、Cookie に `uid=example` という値を埋め込んで再度アクセスしてみると、今度はちゃんと鍵ファイル `/keys/0001.key` がダウンロードできるので、正常に動画が再生できる。

![SnapCrab_NoName_2021-6-12_18-5-57_No-00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/502582/a2201cfd-59de-6b0c-c38f-1e8e3158ea8d.png)

また、テーブルには `example` と `/keys/0001.key` の組み合わせしかデータが存在しない。 そのため、ほぼ同様だが、鍵ファイルを `/keys/0002.key` に変換したページを見ようとすると、例え Cookie に `uid=example` が入っていても鍵ファイルのダウンロードに失敗し、動画を見ることができなくなる。

![SnapCrab_NoName_2021-6-12_18-9-48_No-00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/502582/7ad98562-b185-c060-e22c-f6588c502ebc.png)

Lambda@Edge のビュワーリクエストによって失敗した場合、**例え CloudFront のエッジロケーションにキャッシュされているコンテンツであってもリクエスト実施者にはキャッシュ内容を返答しない** ため、Lambda@Edge が許可しないユーザーに鍵がダウンロードされることはない。
構造上、動画の本体である `.m3u8` や `.ts` ファイルは URL さえ知っていればダウンロードできることはできるが、その実体は暗号化されている。 結果、鍵がないと動画を閲覧することができない。

また、ここでは Cookie を使っているが、Cognito + JWT トークンを使っていればログイン中のユーザーIDが JWTトークンをデコードすることで取得できるため、Cookieではなくこれを利用しても良い。 この周りのユーザー識別技術に何を採用するかはプロダクトの事情で決めるのが良いだろう。


# 記事のまとめ

最初に提示した要件を以下の通り解消した。

> - 配信動画ファイルはLIVEではなく、VOD配信の固定動画ファイルを配信すること
> - メジャーなブラウザ・OSでブラウザ上で動画を閲覧できる動画フォーマットであること
> - ユーザーが抜け道を使って動画ファイルをダウンロードすることは対処しない
    - いわゆる商用の DRM プラットフォームを使っての動画の保護は行わない
    - が、可能な限り対処はしたい

**⇒ 暗号化HLS形式を利用し、固定動画ファイルを配信している。 また、hls.js を使うことでメジャーなブラウザでの動画再生を可能とした**

> - (当たり前だが) URLを外部にコピーした場合、そのリンクを踏んでも権限のないユーザーが動画を見ることはできないこと

**⇒ 動画のファイルこそ頑張ればダウンロードできるものの、動画に暗号化が施されているため、鍵がないと動画が再生できない**

> - 動画ファイルを CloudFront で極力キャッシュし、オリジン側への負荷を減らすこと

**⇒ Lambda@Edge のビューワーリクエストタイミングで判断することで、CDNキャッシュの仕組みを利用でき、例え正常なコンテンツがキャッシュされている状態でアクセスがあったとしても、不正なアクセスを弾くことができる**

**⇒ 鍵のみを認証対象とすることで、動画そのものへのアクセスに認証を行わなくても権限のあるユーザーのみが動画再生を可能とした。 結果として、Lambda@Edge の実行回数が減り、システム認証の負荷・ファイルアクセスのレイテンシーを十分減らしている**


ちなみに、他の方法としては [HLSファイルの `.m3u8` ファイルを Lambda@Edge で書き換えて許可のある人にだけ `.ts` ファイルの一時URLを作成したり](https://qiita.com/kinocoffeeblack/items/e1ece9d47ea948ef6708) 、[`.m3u8` ファイル内の鍵ファイルのパスを動的に差し替えて鍵を取得してくることで本記事と同じ制限をかける](https://blog.foresta.me/posts/hook_decryptkey_request_on_hls_js/) と言った方法もある。

このように、回答は１つではないため、プロジェクトにあった方式を採用するのが良いだろう。


# 追記: MediaPackage の利用とアダプティブ・ストリーミングについて

もし可変サイズの動画配信 ([アダプティブ・ビットレート・ストリーミング](https://en.wikipedia.org/wiki/Adaptive_bitrate_streaming)。 日本語では単にアダプティブストリーミングと説明されている)に対応したい場合、MediaConvert での HLS 形式のアウトプットで複数の出力を作れば、大元の `.m3u8` ファイル内に個別出力のメタファイルへのパスが記載されるため、特に追加の設定をする必要なくアダプティブストリーミングを実現できる。

こういった場合に MediaPackage を挟むこともできるが、サービス概要を見る限り、MediaPackage は必要に応じてリアルタイムで動画変換を行い、それを適時配信する。 個人的には一見「VODに対してこの機能が必要…？」とは思ったが、「対応形式」と「対応解像度」が増えれば増えるほど、変換にかかる時間とそれらの変換後の動画ファイルを保持し続けるコストが増える。今回のテストで使った数MBぐらいの動画であれば、事前に数十の形式に変換してS3に置いていてもディスク総容量・変換時間は大したことはないだろうが、大容量の動画の場合 **そもそも見られるかどうか分からない形式のものまで事前に変換して動画を配置する** だけでなく **それ以後もずっと維持しておかなければならない。**

近年の動画ファイルであれば、1GBあるものも珍しくないはずなので、これを10種類に変換したとすると、ざっくり10GBの動画を保持し続けることになる。 これを MediaPackage ありなしで1年保存すると仮定すると、

- MediaPackage なしの料金: 10回のMediaConvertの変換料金 + 10GBのS3保存料金×1年分
- MediaPackage ありの場合: 元動画(1GB)のS3保存料金×1年分 + MediaPackage の利用料金

となる。 前提条件として「MediaPackageの利用量が少ない」ということがあれば、後者が安くなることも考えられる。　1年というスパンで見た場合でもそうなのだが、時系列とともにユーザーが動画を閲覧する頻度は少なくなると考えられるが、コンテンツの提供は続けられることが普通である。 こういった場合に MediaPackage を挟んでおくことで、最終的な合計支払いコストを低くすることが期待できる。

それだけでなく、MediaPackage は動画配信の直前に挟むレイヤーサービスでもある。 言い換えると、今後、より需要の高い動画フォーマットが出現した場合でも、ここで変換を仕組みを事前に組み込んでおくことで、将来的なシステムの切り替えコストが低くなることが期待できる。

https://aws.amazon.com/jp/about-aws/whats-new/2019/05/aws-elemental-mediapackage-adds-support-for-just-in-time-packaging-for-vod/

公式発表では VOD の配信に MediaPackage を使う場合のメリットをさらっと紹介しているが、小さいサイズの動画しか扱わない場合だとここに書いてあることがピンと来なかったので、改めてこちらの内容を読み解いてみた。

なのでまとめると、

- 本記事の仕組みを使うのであればMediaPackage は必要ない
    - アダプティブストリーミングを行いたい場合、事前に複数の解像度・設定でMediaConvertによる変換をして、S3上に保存しておく必要がある
- MediaPackage の出力を HLS 形式のみに絞れば MediaPackage を使える
    - MediaPackage のメリットの１つは多種多様な動画形式の配信だが、HLS形式以外で出力された動画には本記事の仕組みを使えない
    - MediaPackage には静的キーを使っての暗号化の方式が存在しないため AWS SPEKE 準拠のキーサーバーを立ててやる必要がある ([参考](https://dev.classmethod.jp/articles/aws-mediaservices-speke/))


# 参考

https://d1.awsstatic.com/webinars/jp/pdf/services/20200331_AWSBlackBelt_MediaConvert.pdf

https://blog.foresta.me/posts/hook_decryptkey_request_on_hls_js/

https://forums.aws.amazon.com/thread.jspa?messageID=949494

https://qiita.com/tomopyonsama/items/e5206e34f3ca2aa9290d