tk_ch’s blog

インフラエンジニアのブログ

AWS MediaConvertで作成したHLS形式動画とAES鍵へのアクセスを、CloudFront署名付きCookieでユーザグループ単位に制限する

以下記事で、AWS MediaConvertで作成したHLS形式動画へのアクセスを、CloudFront署名付きCookieでアクセス制限してみた。
今回はその続き。
HLS形式動画を暗号化して、よりセキュアにしてみたい。

tk-ch.hatenablog.com

○上記記事で実現したこと

  • AWSにアップロードした動画ファイルをHLS形式(HTTP Live Streaming)で配信する。
  • 運用コストを抑える&拡張を容易にするため、HLS形式への変換や、HLS動画の配信基盤はサーバーレスでやりたい(EC2の管理したくない)。
  • 特定のユーザ(何らかの認証に通ったユーザ)のみが動画を視聴できるようにする。
  • ユーザの権限(所属グループ)に応じて視聴できる動画を制御する(グループ001所属ユーザは、グループ001所属のユーザがアップロードした動画しか視聴できない、といったイメージ)。

○今回追加で実現したいこと

  • HLS形式の動画を、AES鍵で暗号化する。AES鍵は動画ごとに異なるものにする(鍵が流出した場合にすべての動画が復号化できてしまうことを避けるため)。
  • HLS形式動画同様、特定のユーザ(何らかの認証に通ったユーザ)のみがAES鍵にアクセスできるようにする。
  • さらに、ユーザの権限(所属グループ)に応じて取得できるAES鍵を制御する(グループ001所属ユーザは、グループ001所属のユーザがアップロードした動画に紐づくAES鍵しか取得できない、といったイメージ)。 →仮に動画が悪意のある第三者に取得されても、AES鍵がなければ再生することができないため、よりセキュアになる。

※より強力なコンテンツ保護手段として、「Digital Rights Management(デジタル著作権管理、DRM)」があるが、コストも手間もかかりそうなので、より手軽なHLS+AESを試してみることにした。
コンテンツ保護についての概要は「動画配信におけるコンテンツ保護の重要性とそれを実現する仕組みを自分なりにまとめてみた」が参考になる。

環境

  • Terraform:1.2.5

構成

構成図

今回構築する環境は以下の通り。

構成図
構成図

説明

  • 大まかな構成は前回の記事と同じ。
  • MediaConvertにジョブ登録するLambdaを修正して、ジョブ定義にAES鍵での暗号化を有効化する設定を含める。
  • 暗号化に必要なAES鍵は動画ごとに変えたいので、ジョブ登録するLambda内で、都度AES鍵を生成してその情報をジョブ定義に含めることにした。
  • Lambda内で生成した鍵は、AES鍵用S3バケットを用意してアップロードすることにした(HLS形式動画と鍵が同じバケットに存在すると、そのバケット不正アクセスされた場合に復号できてしまうため)。
    AES鍵は認証済みユーザからはアクセスできる必要があるので、HLS形式動画と同様、CloudFrontで公開して署名付きCookieでアクセスを制限する。
  • HLS動画用S3バケットとAES鍵格納用S3バケットで、公開に使用するCloudfrontディストリビューションは共用することにした(詳細はこの後の補足参照)。
    Cloudfrontディストリビューションに、以下のようにパスに応じてオリジンとなるS3バケットを振り分ける設定(マルチオリジン)をする。
    • パスがkey/*の場合→AES鍵用S3にアクセス
    • パスがvideo/*の場合→HLS動画用S3にアクセス
  • AES鍵へのアクセスをユーザの所属グループ単位で制御する要件は、HLS形式動画と同様、CloudFront署名付きCookieのカスタムポリシーで実現する。
    動作確認の都合上、同じCookieでAES鍵にもHLS動画にもアクセスできるようにする。
    ※AES鍵とHLS動画で異なるCookieが必要な設定にもできるが、動作確認時に2種類のCookieを設定する方法がうまくいかなかったのでやめた。

補足:CloudFrontの構成について

今回は、HLS動画用S3バケットとAES鍵格納用S3バケットでCloudFrontのディストリビューションを共用することにしたが、当初は以下のように分ける構成を検討していた。

[構成図没案]
構成図没案

→カスタムドメインを使わずにCloudFront がディストリビューションに割り当てたドメイン名をそのまま使っている場合、「*.cloudfront.net」といったドメイン指定でCookieを利用することができない。
今回は使えるカスタムドメインがなく、Cookieも2つのCloudFrontで共用したかったので、この案は没にした。
※「*.cloudfront.net」をCookieのDomainに指定できない件は、このページに以下の記載がある。

CloudFront がディストリビューションに割り当てたドメイン名 (d111111abcdef8.cloudfront.net など) を指定することはできますが、*.cloudfront.net をドメイン名として指定することはできません。

商用環境で使う場合にはカスタムドメインがあると思うので、以下のようにCloudFrontディストリビューションを分けても問題ない見込み(未検証)。

[構成図カスタムドメイン使用時]
構成図カスタムドメイン使用時

実施内容

Terraformでのリソース変更・追加

前回の記事で使用したTerraformコードを変更する。

変更・追加するリソース

Terraformで変更・追加するリソースの概要は以下の通り。

  • AES鍵用S3バケットを追加。
  • CloudFrontに、key/*へのアクセスの場合AES鍵用S3バケットをオリジンとして使用し、かつ署名付きCookieがないとアクセスできないよう設定。
  • MediaConvertジョブ登録用Lambda関数からAES鍵用S3バケットにAES鍵をアップロードするため、Lambda実行ロールのIAMポリシーにAES鍵用S3バケットへのPUT権限を追加。

Terraformコード

Terraformコードは以下の通り。
「### 変更箇所ここから」と「### 変更箇所ここまで」の間が前記事のコードからの変更箇所。

local.tf

# ローカル変数定義
locals {

  name_prefix = "hlstest"
  common_tags = {
    Terraform = "True" #Terraformで作成したリソースだとわかるようにタグを付ける。付けなくても処理には影響ない。
  }

}

main.tf

# オリジナル動画用S3バケット
module "s3_bucket_source" {
  source  = "terraform-aws-modules/s3-bucket/aws"
  version = "3.3.0"

  bucket        = "${local.name_prefix}-s3-source"
  force_destroy = false

  # デフォルトの暗号化。原則S3マスターキーでの暗号化を有効にする。
  server_side_encryption_configuration = {
    rule = {
      apply_server_side_encryption_by_default = {
        sse_algorithm = "AES256"
      }
    }
  }

  # ブロックパブリックアクセス。原則全てtrueにする。
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-s3-source"
  })
}

# オリジナル動画用S3バケットにmp4がアップロードされた際のS3イベント通知
module "s3_notifications_hls" {
  source  = "terraform-aws-modules/s3-bucket/aws//modules/notification"
  version = "3.3.0"

  bucket = module.s3_bucket_source.s3_bucket_id

  # Common error - Error putting S3 notification configuration: InvalidArgument: Configuration is ambiguously defined. Cannot have overlapping suffixes in two rules if the prefixes are overlapping for the same event type.

  sns_notifications = {
    video_uploaded = {
      topic_arn     = aws_sns_topic.video_upload.arn
      events        = ["s3:ObjectCreated:*"]
      filter_prefix = "video/"
      filter_suffix = ".mp4"
    }
  }

  create_sns_policy = true
}

# S3イベント通知の送信先SNSトピック
resource "aws_sns_topic" "video_upload" {
  name = "${local.name_prefix}-sns-upload"

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-sns-upload"
  })
}

# S3イベント通知の送信先SQSキュー
resource "aws_sqs_queue" "video_upload" {
  name = "${local.name_prefix}-sqs-upload"

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-sqs-upload"
  })
}

# S3イベント通知の送信先SQSキューのポリシー設定
resource "aws_sqs_queue_policy" "sqs_video_upload_policy" {
  queue_url = aws_sqs_queue.video_upload.id
  policy    = data.aws_iam_policy_document.sqs_video_upload_policy.json
}

# AWSアカウントIDを取得
data "aws_caller_identity" "now" {}

# S3イベント通知の送信先SQSキューのポリシー内容
data "aws_iam_policy_document" "sqs_video_upload_policy" {
  statement {
    sid = "__owner_statement"
    principals {
      type        = "AWS"
      identifiers = ["arn:aws:iam::${data.aws_caller_identity.now.account_id}:root"]
    }
    effect    = "Allow"
    actions   = ["SQS:*"]
    resources = ["${aws_sqs_queue.video_upload.arn}"]
  }
  statement {
    sid = "Allow-SNS-SendMessage"
    principals {
      type        = "Service"
      identifiers = ["sns.amazonaws.com"]
    }
    effect    = "Allow"
    actions   = ["sqs:SendMessage"]
    resources = ["${aws_sqs_queue.video_upload.arn}"]
    condition {
      test     = "ArnEquals"
      variable = "aws:SourceArn"
      values   = ["${aws_sns_topic.video_upload.arn}"]
    }
  }
}

# SNSトピックからSQSキューへのサブスクライブ設定
resource "aws_sns_topic_subscription" "sqs_request_mosaic" {
  topic_arn = aws_sns_topic.video_upload.arn
  protocol  = "sqs"
  endpoint  = aws_sqs_queue.video_upload.arn
}

# HLS動画用S3バケット
module "s3_bucket_hls" {
  source  = "terraform-aws-modules/s3-bucket/aws"
  version = "3.3.0"

  bucket        = "${local.name_prefix}-s3-hls"
  force_destroy = false

  attach_policy = true
  policy        = data.aws_iam_policy_document.s3_bucket_hls_policy.json

  # デフォルトの暗号化。原則S3マスターキーでの暗号化を有効にする。
  server_side_encryption_configuration = {
    rule = {
      apply_server_side_encryption_by_default = {
        sse_algorithm = "AES256"
      }
    }
  }

  # ブロックパブリックアクセス。原則全てtrueにする。
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true

  # CORS設定
  cors_rule = [
    {
      allowed_methods = ["GET"]
      allowed_origins = ["*"]
      allowed_headers = ["*"]
      expose_headers  = []
      max_age_seconds = 3000
    }
  ]

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-s3-hls"
  })
}

# HLS動画用S3バケットのバケットポリシー内容
data "aws_iam_policy_document" "s3_bucket_hls_policy" {
  statement {
    principals {
      type        = "AWS"
      identifiers = module.cloudfront.cloudfront_origin_access_identity_iam_arns
    }
    actions = [
      "s3:GetObject",
    ]
    resources = [
      "${module.s3_bucket_hls.s3_bucket_arn}/*",
    ]
  }
}

# HLS配信用CloudFront
module "cloudfront" {
  source  = "terraform-aws-modules/cloudfront/aws"
  version = "2.8.0"

  enabled             = true
  is_ipv6_enabled     = true
  price_class         = "PriceClass_All"
  retain_on_delete    = false # terraform destroy時に削除でなく無効にするか
  wait_for_deployment = false
  default_root_object = "index.html"

  create_origin_access_identity = true
  origin_access_identities = {
    s3_bucket_one = "access-identity-${module.s3_bucket_hls.s3_bucket_bucket_regional_domain_name}"
  }

  origin = {
    "${module.s3_bucket_hls.s3_bucket_bucket_regional_domain_name}" = {
      domain_name = module.s3_bucket_hls.s3_bucket_bucket_regional_domain_name
      s3_origin_config = {
        origin_access_identity = "s3_bucket_one" # key in `origin_access_identities`
      }
    }
### 変更箇所ここから
    "${module.s3_bucket_aeskey.s3_bucket_bucket_regional_domain_name}" = {
      domain_name = module.s3_bucket_aeskey.s3_bucket_bucket_regional_domain_name
      s3_origin_config = {
        origin_access_identity = "s3_bucket_one" # key in `origin_access_identities`
      }
    }
### 変更箇所ここまで
  }

  default_cache_behavior = {
    target_origin_id       = module.s3_bucket_hls.s3_bucket_bucket_regional_domain_name
    viewer_protocol_policy = "redirect-to-https"
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    use_forwarded_values   = false # https://github.com/hashicorp/terraform-provider-aws/issues/19041
    cache_policy_id        = data.aws_cloudfront_cache_policy.managed_caching_disabled.id
  }

  ordered_cache_behavior = [
    {
      path_pattern           = "video/*"
      target_origin_id       = module.s3_bucket_hls.s3_bucket_bucket_regional_domain_name
      viewer_protocol_policy = "redirect-to-https"
      allowed_methods        = ["GET", "HEAD"]
      cached_methods         = ["GET", "HEAD"]
      compress               = true
      trusted_key_groups     = [aws_cloudfront_key_group.key_group_hls.id]
      use_forwarded_values   = false # https://github.com/hashicorp/terraform-provider-aws/issues/19041
      cache_policy_id        = data.aws_cloudfront_cache_policy.managed_caching_disabled.id
### 変更箇所ここから
    },
    {
      path_pattern           = "key/*"
      target_origin_id       = module.s3_bucket_aeskey.s3_bucket_bucket_regional_domain_name
      viewer_protocol_policy = "redirect-to-https"
      allowed_methods        = ["GET", "HEAD"]
      cached_methods         = ["GET", "HEAD"]
      compress               = true
      trusted_key_groups     = [aws_cloudfront_key_group.key_group_hls.id]
      use_forwarded_values   = false # https://github.com/hashicorp/terraform-provider-aws/issues/19041
      cache_policy_id        = data.aws_cloudfront_cache_policy.managed_caching_disabled.id
    }
### 変更箇所ここまで
  ]

  tags = local.common_tags
}

# CloudFrontのキャッシュポリシー
data "aws_cloudfront_cache_policy" "managed_caching_disabled" {
  name = "Managed-CachingDisabled"
}

# 公開鍵とキーグループ
resource "aws_cloudfront_public_key" "key_hls" {
  name        = "${local.name_prefix}-cloudfront-public-key"
  comment     = "HLS配信用パブリックキー"
  encoded_key = file("cloudfront_key/public_key.pem")
}
resource "aws_cloudfront_key_group" "key_group_hls" {
  name    = "${local.name_prefix}-cloudfront-key-group"
  comment = "HLS配信用キーグループ"
  items   = [aws_cloudfront_public_key.key_hls.id]
}

# MediaConvert用IAMポリシー
resource "aws_iam_policy" "mediaconvert" {
  name   = "${local.name_prefix}-iampol-mediaconvert"
  policy = data.aws_iam_policy_document.mediaconvert_policy.json

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-iampol-mediaconvert"
  })
}

# MediaConvert用IAMポリシーの内容
data "aws_iam_policy_document" "mediaconvert_policy" {
  statement {
    sid     = "AllowGetSourceVideo"
    effect  = "Allow"
    actions = ["s3:GetObject"]
    resources = [
      "${module.s3_bucket_source.s3_bucket_arn}",
      "${module.s3_bucket_source.s3_bucket_arn}/*"
    ]
  }
  statement {
    sid     = "AllowPutHLSVideo"
    effect  = "Allow"
    actions = ["s3:PutObject"]
    resources = [
      "${module.s3_bucket_hls.s3_bucket_arn}",
      "${module.s3_bucket_hls.s3_bucket_arn}/*"
    ]
  }
}

# MediaConvert用IAMロール
resource "aws_iam_role" "mediaconvert" {
  name                = "${local.name_prefix}-role-mediaconvert"
  managed_policy_arns = [aws_iam_policy.mediaconvert.arn]

  assume_role_policy = data.aws_iam_policy_document.mediaconvert_assume_role_policy.json

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-role-mediaconvert"
  })
}

# MediaConvert用IAMロールの信頼ポリシー内容
data "aws_iam_policy_document" "mediaconvert_assume_role_policy" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["mediaconvert.amazonaws.com"]
    }
  }
}

# MediaConvertジョブ登録Lambda用IAMポリシー
resource "aws_iam_policy" "lambda_submit_mcjob" {
  name   = "${local.name_prefix}-iampol-lambda_submit_mcjob"
  policy = data.aws_iam_policy_document.lambda_submit_mcjob_policy.json

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-iampol-lambda_submit_mcjob"
  })
}

# MediaConvertジョブ登録Lambda用IAMポリシー内容
data "aws_iam_policy_document" "lambda_submit_mcjob_policy" {
  statement {
    sid     = "AllowPassRole"
    effect  = "Allow"
    actions = ["iam:PassRole"]
    resources = [
      "${aws_iam_role.mediaconvert.arn}"
    ]
  }
  statement {
    sid     = "AllowCreateMCJob"
    effect  = "Allow"
    actions = ["mediaconvert:CreateJob"]
    resources = [
      "arn:aws:mediaconvert:ap-northeast-1:${data.aws_caller_identity.now.account_id}:*"
    ]
  }
### 変更箇所ここから
  statement {
    sid    = "AllowPutAesKey"
    effect = "Allow"
    actions = [
      "s3:PutObject"
    ]
    resources = ["${module.s3_bucket_aeskey.s3_bucket_arn}/*"]
  }
### 変更箇所ここまで
}

# MediaConvertジョブ登録Lambda用IAMロール
resource "aws_iam_role" "lambda_submit_mcjob" {
  name = "${local.name_prefix}-role-lambda_submit_mcjob"
  managed_policy_arns = [
    aws_iam_policy.lambda_submit_mcjob.arn,
    data.aws_iam_policy.AWSLambdaSQSQueueExecutionRole.arn
  ]

  assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_policy.json

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-role-lambda_submit_mcjob"
  })
}

# SQSトリガーのLambda用AWS管理ポリシー取得
data "aws_iam_policy" "AWSLambdaSQSQueueExecutionRole" {
  arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaSQSQueueExecutionRole"
}

# MediaConvertジョブ登録Lambda用IAMロールの信頼ポリシー
data "aws_iam_policy_document" "lambda_assume_role_policy" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}

### 変更箇所ここから
# AES鍵用S3バケット
module "s3_bucket_aeskey" {
  source = "../../../modules/terraform-aws-s3-bucket-3.3.0"

  bucket        = "${local.name_prefix}-s3-aeskey"
  force_destroy = false

  attach_policy = true
  policy        = data.aws_iam_policy_document.s3_bucket_aeskey_policy.json

  # デフォルトの暗号化。原則S3マスターキーでの暗号化を有効にする。
  server_side_encryption_configuration = {
    rule = {
      apply_server_side_encryption_by_default = {
        sse_algorithm = "AES256"
      }
    }
  }

  # ブロックパブリックアクセス。原則全てtrueにする。
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true

  # CORS設定
  cors_rule = [
    {
      allowed_methods = ["GET"]
      allowed_origins = ["*"]
      allowed_headers = ["*"]
      expose_headers  = []
      max_age_seconds = 3000
    }
  ]

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-s3-aeskey"
  })
}

# HLS配信用S3バケットのバケットポリシー内容
data "aws_iam_policy_document" "s3_bucket_aeskey_policy" {
  statement {
    principals {
      type        = "AWS"
      identifiers = module.cloudfront.cloudfront_origin_access_identity_iam_arns
    }
    actions = [
      "s3:GetObject",
    ]
    resources = [
      "${module.s3_bucket_aeskey.s3_bucket_arn}/*",
    ]
  }
}
### 変更箇所ここまで

MediaConvertジョブ登録用Lambda関数の修正

マネジメントコンソールで、Lambda関数を修正する。
AES鍵を生成する処理のコードはこちらを参考にさせていただいた。

処理概要

修正後のLambda関数内で実行する処理の概要は以下。

  • SQSから受信したメッセージからS3イベントを取り出す。
  • アップロードされたmp4ファイルのパスから、HLS動画用S3バケットのどのパスに出力するか決定する。
    (オリジナル動画用S3バケットの「video/<グループ名>/<mp4ファイルID>.mp4」としてアップロードされたmp4動画の出力先は、HLS動画用S3バケットの「video/<グループ名>/hls_<mp4ファイルID>/」配下になる。)
  • (追加した処理)HLS形式動画の暗号化に必要なAES鍵と定数初期化ベクトルを生成する。
  • (追加した処理)AES鍵の格納先URLを決定する。
    (オリジナル動画用S3バケットの「video/<グループ名>/<mp4ファイルID>.mp4」としてアップロードされたmp4動画に対応するAES鍵の格納先は、AES鍵用S3バケットの「key/<グループ名>/hls_<mp4ファイルID>/<mp4ファイルID>.key」になる。)
  • (変更した処理)事前に作成したMediaConvertのテンプレートを元に、mp4とHLS出力先のパス、AES鍵の値とURL、定数初期化ベクトルを指定して、MediaConvertジョブを登録する。
  • (追加した処理)AES鍵をAES鍵用S3バケットにアップロードする。

関数作成時の注意点

  • 関数の実行ロールはTerraformで作成した「hlstest-role-lambda_submit_mcjob」を指定する。
  • トリガーにはTerraformで作成したSQSキュー「hlstest-sqs-upload」を指定する。
  • 環境変数に以下を設定する。
    • JOB_TEMPLATE_NAME:MediaConvertのジョブテンプレート名。今回はTerraformで作成した「hlstest-mcjob-template」。
    • MEDIACONVERT_ENDPOINT:MediaConvertのAPI エンドポイント。マネジメントコンソールのMediaConvertのページで、「アカウント」を選択すると表示される。
    • MEDIACONVERT_ROLE:MediaConvert実行用のIAMロールのARN。今回はTerraformで作成した「hlstest-role-mediaconvert」のARN。
    • OUTPUT_S3_NAME:HLS形式動画の出力先S3バケットの名前。今回はTerraformで作成した「hlstest-s3-hls」。
    • AESKEY_S3_NAME:AES鍵用S3バケットの名前。今回はTerraformで作成した「hlstest-s3-aeskey」。

関数コード

import os
import urllib.parse
import boto3
import json
import secrets
import codecs
import re

# MediaConvertのエンドポイントURL
MEDIACONVERT_ENDPOINT = os.environ['MEDIACONVERT_ENDPOINT']

# MediaConvert用のロールのARN
MEDIACONVERT_ROLE = os.environ['MEDIACONVERT_ROLE']

# 前回作成したジョブテンプレートの名前
JOB_TEMPLATE_NAME = os.environ['JOB_TEMPLATE_NAME']

# 変換後ファイルの出力先S3のバケット名
OUTPUT_S3_NAME = os.environ['OUTPUT_S3_NAME']

# AES鍵の格納先S3のバケット名
AESKEY_S3_NAME = os.environ['AESKEY_S3_NAME']

# AES鍵の格納先S3を公開しているCloudFrontドメイン名
AESKEY_CLOUDFRONT_DOMAIN = os.environ['AESKEY_CLOUDFRONT_DOMAIN']

s3Client = boto3.client('s3')

def lambda_handler(event, context):
    print(f'boto3 version: {boto3.__version__}')
    
    for record in event['Records']:
        message_str = json.loads(record["body"])['Message']
        message = json.loads(message_str)
    
        bucket = message['Records'][0]['s3']['bucket']['name']
        key = urllib.parse.unquote_plus(message['Records'][0]['s3']['object']['key'], encoding='utf-8')

        # 16バイトのランダムなデータを16進数文字列として生成(MediaConvertの「静的キーの値」として使用する)
        aes_key_value = secrets.token_hex(16)
        # 16バイトのランダムなバイナリデータに変換(MediaConvertの「静的キー」の中身になる)
        aes_key_binary = codecs.decode(aes_key_value, 'hex_codec')
        # 16バイトのランダムなデータを16進数文字列として生成(MediaConvertの「定数初期化ベクトル」として使用する)
        iv = secrets.token_hex(16)
    
        # 静的キーをAES鍵格納用S3にアップロード
        basename_video = os.path.basename(key).split('.')[0]
        dirname_key = re.sub('^video', 'key', os.path.dirname(key))
        aes_key = dirname_key + '/' + basename_video + '/' + basename_video + '.key'
        print(s3Client.put_object(
            Bucket = AESKEY_S3_NAME,
            Key = aes_key,
            Body = aes_key_binary
        ))
    
        settings = make_settings(bucket, key, aes_key_value, iv, aes_key)
        user_metadata = {
            'JobCreatedBy': 'videoConvertSample',
        }
    
        client = boto3.client('mediaconvert', endpoint_url = MEDIACONVERT_ENDPOINT)
        result = client.create_job(
            Role = MEDIACONVERT_ROLE,
            JobTemplate = JOB_TEMPLATE_NAME,
            Settings=settings,
            UserMetadata=user_metadata,
        )

def make_settings(bucket, key, aes_key_value, iv, aes_key):
    basename = os.path.basename(key).split('.')[0]
    dirname = os.path.dirname(key)

    return \
    {
        "Inputs": [
            {
                "FileInput": f"s3://{bucket}/{key}",
            }
        ],
        "OutputGroups": [
            {
                "Name": "Apple HLS",
                "OutputGroupSettings": {
                    "Type": "HLS_GROUP_SETTINGS",
                    "HlsGroupSettings": {
                        "Destination": f"s3://{OUTPUT_S3_NAME}/{dirname}/hls_{basename}/",
                        "Encryption": {
                            "EncryptionMethod": "AES128",
                            "ConstantInitializationVector": iv,
                            "StaticKeyProvider": {
                                "StaticKeyValue": aes_key_value,
                                "Url": 'https://' + AESKEY_CLOUDFRONT_DOMAIN + '/' + aes_key
                            },
                            "Type": "STATIC_KEY"
                        }
                    },
                },
            },
        ],
    }

mp4動画をAES鍵で暗号化されたHLS形式に変換する

001グループ用のHLS形式動画を作成

前回同様、適当に用意した「test.mp4」を、オリジナル動画用S3バケットの「video/001/」にアップロードする。
実行されたジョブはMediaConvertのページの「ジョブ」から確認でき、該当するジョブの「ジョブの詳細」→「出力グループ」→「Apple HLS」と進むと「DRM 暗号化」という項目と暗号化に利用したAES鍵の情報を参照できる。

HLS動画用S3バケットを確認すると、以下のように「video/001/hls_test」配下にHLS形式のインデックスファイルとセグメントファイルが出力されている。

グループ001用のHLS形式ファイル
グループ001用のHLS形式ファイル

作成されたインデックスファイルの中身は以下。
「test_Ott_Hls_Ts_Avc_Aac_16x9_640x360p_1.5Mbps_qvbr.m3u8」に、「#EXT-X-KEY:METHOD」というHLS形式動画の復号に使うAES鍵の情報が含まれていることが確認できる。
※<CloudFrontのドメイン名>には実際にはドメイン名が入る。

test.m3u8

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-STREAM-INF:BANDWIDTH=1011712,AVERAGE-BANDWIDTH=980138,CODECS="avc1.64001e,mp4a.40.5",RESOLUTION=640x360,FRAME-RATE=30.000
test_Ott_Hls_Ts_Avc_Aac_16x9_640x360p_1.5Mbps_qvbr.m3u8

test_Ott_Hls_Ts_Avc_Aac_16x9_640x360p_1.5Mbps_qvbr.m3u8

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:3
#EXT-X-MEDIA-SEQUENCE:1
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-KEY:METHOD=AES-128,URI="https://<CloudFrontのドメイン名>/key/001/hls_test/test.key",IV=0xB7293945FA8669A6B29B3484F15560F7
#EXTINF:3,
test_Ott_Hls_Ts_Avc_Aac_16x9_640x360p_1.5Mbps_qvbr_00001.ts
#EXTINF:3,
test_Ott_Hls_Ts_Avc_Aac_16x9_640x360p_1.5Mbps_qvbr_00002.ts
#EXTINF:1,
test_Ott_Hls_Ts_Avc_Aac_16x9_640x360p_1.5Mbps_qvbr_00003.ts
#EXT-X-ENDLIST

また、AES鍵用S3バケットを確認すると、以下のように「key/001/hls_test」配下にAES鍵が配置されている。

AES鍵ファイル(001)
AES鍵ファイル(001)

002グループ用のHLS形式動画を作成

「test.mp4」を、オリジナル動画用S3バケットの「video/002/」にアップロードする。 先ほどと同様にMediaConvertジョブが実行され、HLS動画とAES鍵がそれぞれのS3バケットに出力される。

curlコマンドで接続確認する

curlコマンドで、HLS形式動画とAES鍵にアクセス署名付きCookieを持つユーザのみがアクセス出来ることを確認する。
記載している手順はインターネットに接続できる環境で実行すること。
また、<CloudFrontのドメイン名>にはTerraformで作成したCloudFrontディストリビューションドメイン名を入れる。

署名付きCookie無しではアクセスできないことを確認

CloudFrontドメイン名を設定
$ CF_DOMAIN=<CloudFrontのドメイン名>

グループ001用のHLS形式ファイル(インデックスファイル)へアクセスする
$ curl https://${CF_DOMAIN}/video/001/hls_test/test.m3u8
<?xml version="1.0" encoding="UTF-8"?><Error><Code>MissingKey</Code><Message>Missing Key-Pair-Id query parameter or cookie value</Message></Error>

$ curl https://${CF_DOMAIN}/key/001/hls_test/test.key
<?xml version="1.0" encoding="UTF-8"?><Error><Code>MissingKey</Code><Message>Missing Key-Pair-Id query parameter or cookie value</Message></Error>

→CloudFrontにて「video/*」と「key/*」に署名付きCookieの設定をしているため、Cookieの無い状態でこれらのパスにアクセスすると「Missing Key-Pair-Id query parameter or cookie value」というエラーになる。

署名付きCookieを使うとアクセスできることを確認

次に、署名付きCookieを使えばアクセスできることを確認する。

署名付きCookie生成用のカスタムポリシーを用意する

カスタムポリシーのJSONファイルを作成する。
グループ001とグループ002でアクセスできるパスを変えたいので、それぞれのJSONファイルを作成する。
 

  • Resourceにはアクセスを許可するURLを記載する。今回は同じCookieでAES鍵にもHLS動画にもアクセスできるようにしたいので、「video/グループ名/*」にも「key/グループ名/*」も許可対象になるよう、「*/グループ名/*」と指定する。
    <CloudFrontのドメイン名>はCloudFrontのドメイン名に書き換える。
  • DateLessThanはURL の有効期限切れ日時。Unix 時間形式 (秒単位) および協定世界時 (UTC) で指定する。
    今回は適当に未来日時を「$ date -d "2023-11-11 08:38:25" +%s」を実行してエポックタイムに変換したものを記載している。

policy_aeskey_001.json

{
    "Statement": [
        {
            "Resource": "https://<CloudFrontのドメイン名>/*/001/*",
            "Condition": {
                "DateLessThan": {
                    "AWS:EpochTime": 1699691905
                }
            }
        }
    ]
}

policy_aeskey_002.json

{
    "Statement": [
        {
            "Resource": "https://<CloudFrontのドメイン名>/*/002/*",
            "Condition": {
                "DateLessThan": {
                    "AWS:EpochTime": 1699691905
                }
            }
        }
    ]
}

署名付きCookieを作成して、アクセスできるか確認する

以下の作業は秘密鍵とカスタムポリシーJSONファイルがあるディレクトリで行う。 秘密鍵前回作成したものを使っている。

$ ls -l
total 16
-rw-rw-r-- 1 ec2-user ec2-user  266 Nov 15 03:29 policy_aeskey_001.json
-rw-rw-r-- 1 ec2-user ec2-user  266 Nov 15 03:29 policy_aeskey_002.json
-rw-rw-r-- 1 ec2-user ec2-user 1679 Nov 17 05:56 private_key.pem
-rw-rw-r-- 1 ec2-user ec2-user  451 Nov 17 05:57 public_key.pem

まずグループ001のユーザを想定して署名付きCookieを作成する。
以下の手順を実行する。
手順中の<CloudFrontのドメイン名>と<公開鍵のID>は環境に応じて記載する。

CloudFrontドメイン名を環境変数に格納
$ CF_DOMAIN=<CloudFrontのドメイン名>

公開鍵のIDを環境変数に格納
$ CF_KEYPAIR_ID=<公開鍵のID>

ポリシーファイルのパスを環境変数に格納
$ CF_POLICY_FILE=policy_aeskey_001.json

秘密鍵のパスを環境変数に格納
$ CF_PRIVATE_KEY=private_key.pem

カスタムポリシーのJSONファイルをbase64エンコードして環境変数に格納
$ CF_POLICY=$(cat ${CF_POLICY_FILE} | tr -d "\n" | tr -d " \t\n\r" | openssl base64 -A | tr -- '+=/' '-_~')

カスタムポリシーのJSONファイルに秘密鍵で署名し、base64エンコードして環境変数に格納
$ CF_SIGNATURE=$(cat ${CF_POLICY_FILE} | tr -d "\n" | tr -d " \t\n\r" | openssl sha1 -sign ${CF_PRIVATE_KEY} | openssl base64 -A | tr -- '+=/' '-_~')

Cookieをセットして接続すると、グループ001用のパスにあるHLS形式ファイル(インデックスファイル)にアクセスできる
$ curl https://${CF_DOMAIN}/video/001/hls_test/test.m3u8 -b "CloudFront-Policy=${CF_POLICY}; CloudFront-Signature=${CF_SIGNATURE}; CloudFront-Key-Pair-Id=${CF_KEYPAIR_ID}"
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-STREAM-INF:BANDWIDTH=1011712,AVERAGE-BANDWIDTH=980138,CODECS="avc1.64001e,mp4a.40.5",RESOLUTION=640x360,FRAME-RATE=30.000
test_Ott_Hls_Ts_Avc_Aac_16x9_640x360p_1.5Mbps_qvbr.m3u8

Cookieをセットして接続すると、グループ001用のパスにあるAES鍵にアクセスできる(鍵はバイナリファイルなので、中身をちゃんと表示することはできない)
$ curl https://${CF_DOMAIN}/key/001/hls_test/test.key -b "CloudFront-Policy=${CF_POLICY}; CloudFront-Signature=${CF_SIGNATURE}; CloudFront-Key-Pair-Id=${CF_KEYPAIR_ID}"
P[01;32m

Cookieをセットして接続しても、グループ002用のパスにあるHLS形式ファイル、AES鍵にはアクセスできない。
$ curl https://${CF_DOMAIN}/video/002/hls_test/test.m3u8 -b "CloudFront-Policy=${CF_POLICY}; CloudFront-Signature=${CF_SIGNATURE}; CloudFront-Key-Pair-Id=${CF_KEYPAIR_ID}"                           
<?xml version="1.0" encoding="UTF-8"?><Error><Code>AccessDenied</Code><Message>Access denied</Message></Error>

$ curl https://${CF_DOMAIN}/key/002t/test.key -b "CloudFront-Policy=${CF_POLICY}; CloudFront-Signature=${CF_SIGNATURE}; CloudFront-Key-Pair-Id=${CF_KEYPAIR_ID}"                                                                                    
<?xml version="1.0" encoding="UTF-8"?><Error><Code>AccessDenied</Code><Message>Access denied</Message></Error>

後ほどブラウザで確認する際に使うので、以下の値は書き留めておく
$ echo ${CF_POLICY}
$ echo ${CF_SIGNATURE}

次に、グループ002のユーザを想定して署名付きCookieを作成する。
ポリシーJSONファイルだけグループ002用のものに変えて、先ほどと同じ手順を実行すると、002のHLS形式ファイル、AES鍵にのみ接続できることが確認できる。

→カスタムポリシーを使い分けることで、所属グループ(権限)ごとにアクセスできるHLS形式ファイル、AES鍵を制御することができた。

ブラウザで接続確認する

ブラウザからアクセスして、実際に視聴できるか確認する。

Cookieをセットして接続する

前回同様、何らかの方法でブラウザにCookieをセットして接続確認してみる。

curlコマンドでの実行時に作成した、グループ001用署名付きCookieをブラウザにセットして「http://<CloudFrontのドメイン名>」に接続する。
以下のように、グループ001の動画のみ再生でき、グループ002用動画は「The media could not be loaded, either because the server or network failed or because the format is not supported.」となり視聴できない。

グループ001用Cookieを設定して接続した場合
グループ001用Cookieを設定して接続した場合

また、念のため本当にAES鍵で復号して再生しているか確認してみる。
AES鍵用S3バケットにあるAES鍵を削除してブラウザを更新すると、以下のように先ほどまで再生できていた映像がずっと読み込みのグルグル状態になって再生できなくなった。

AES鍵を削除して接続した場合
AES鍵を削除して接続した場合

→想定通りの制限および暗号化が出来ていることを、ブラウザからの接続で確認できた。

まとめ

  • MediaConvertでのHLS形式動画作成時にAES鍵による暗号化ができ、動画コンテンツ保護が行えることを確認できた。

参考文献