ひだまりソケットは壊れない

ソフトウェア開発に関する話を書きます。 最近は主に Android アプリ、Windows アプリ (UWP アプリ)、Java 関係です。

まじめなことを書くつもりでやっています。 適当なことは 「一角獣は夜に啼く」 に書いています。

CloudFront のプライベートコンテンツ配信を試した (Kotlin で signed cookie を生成する)

  • AWSCDN である CloudFront でプライベートコンテンツを配信する方法として、signed URL を用いる方法と、signed cookie を用いる方法がある。
  • 本記事では、signed cookie の生成処理を Kotlin (Java) で行う方法を説明する。

事前準備 (CloudFront 側の準備)

  • アクセスする側の挙動を確認するために、試験的に CloudFront 側も準備したのでその手順を書いておく。 (AWS コンソール上で操作を行った。)
  • 内容は次の 2 つ。

CloudFront のディストリビューションを用意

コンテンツへのアクセスを制限する

キーペアの作成と CloudFront への登録

CloudFront にキーペアを登録する方法としては、ローカルマシン上で作成して公開鍵をアップロードするか、AWS コンソール上で作成して秘密鍵をダウンロードするかのどちらかとのこと。 今回はローカルマシン上で作成した。 コマンドは上記参考ページに載っている。

# 秘密鍵の作成
openssl genrsa -out private_key.pem 4096

# 公開鍵の作成
openssl rsa -pubout -in private_key.pem -out public_key.pem

# Java で使う用に秘密鍵を DER 形式に変換
openssl pkcs8 -topk8 -nocrypt -in private_key.pem -inform PEM -out cloud_front_key.der -outform DER

アカウントメニューから 「セキュリティ認証情報」 画面に行くと 「CloudFront のキーペア」 という項目があるので、ここで公開鍵をアップロードする。 登録された鍵のアクセスキー ID と、上のコマンドで作成された DER 形式の鍵が後で必要になる。

信頼された署名者をディストリビューションに追加する

上で登録した鍵を使って signed cookie を生成してプライベートコンテンツにアクセスできるように、AWS アカウントを信頼された署名者として CloudFront のディストリビューションに登録する。

(ちなみにディストリビューションを作成した AWS アカウント以外のアカウントも登録できるらしい。 今回は自分自身を登録する。)

この段階で、signed cookie や signed url を使わないアクセスはエラーになるようになる。

Signed cookie を生成してプライベートコンテンツにアクセスする (Kotlin)

上で生成された DER 形式の秘密鍵とアクセスキーを使うことで、下記のようなコードでプライベートコンテンツにアクセスできる。 (下記では Java SE 11 から新たに追加された API (java.net.http モジュール) を使用しているので、Java SE 11 でなければ動かない。)

import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.nio.file.Files
import java.nio.file.Paths
import java.security.KeyFactory
import java.security.PrivateKey
import java.security.Signature
import java.security.spec.PKCS8EncodedKeySpec
import java.time.Instant
import java.time.Period
import java.util.*

// CloudFront のドメイン名
private val cloudFrontDomainName = "xxxxx.cloudfront.net"
// AWS コンソール上に表示されている CloudFront のキーペアのアクセス ID
private const val cloudFrontKeyPairId = "xxxx"
// 自分で生成した DER 形式の秘密鍵
private const val cloudFrontKeyPairFile = "cloud_front_key.der"

fun main() {
    // ポリシーステートメントを用意。
    val endingDate = Instant.now().plus(Period.ofDays(1))
    val cloudFrontPolicyStatement = """
        {
           "Statement": [
              {
                 "Resource": "http://$cloudFrontDomainName/",
                 "Condition": {
                    "DateLessThan": { "AWS:EpochTime": ${endingDate.epochSecond} }
                 }
              }
           ]
        }
    """.trimIndent()
    // CloudFront に登録された鍵の情報を用意。
    val keyFactory = KeyFactory.getInstance("RSA")
        // すべての Java 実装で RSA がサポートされる。 Java SE 8 のドキュメントで確認。
    val cloudFrontKeySpec = PKCS8EncodedKeySpec(Files.readAllBytes(Paths.get(cloudFrontKeyPairFile)))
    val cloudFrontPrivateKey = keyFactory.generatePrivate(cloudFrontKeySpec)
    val cloudFrontKey = CloudFrontKey(cloudFrontKeyPairId, cloudFrontPrivateKey)
    // Signed cookie の値を生成。
    val cloudFrontCookieValue = CloudFrontSignedCookieValue.create(cloudFrontPolicyStatement, cloudFrontKey)

    // Signed cookie を使用してプライベートコンテンツにアクセス。
    val targetUri = URI.create("http://$cloudFrontDomainName/")
    val response = request(targetUri, HttpResponse.BodyHandlers.ofString(), cloudFrontCookieValue)
    println(response.body())
}

fun <T> request(
    uri: URI,
    responseBodyHandler: HttpResponse.BodyHandler<T>,
    cloudFrontCookieValue: CloudFrontSignedCookieValue
): HttpResponse<T> {
    val client = HttpClient.newBuilder()
        .build()
    val request = HttpRequest.newBuilder()
        .uri(uri)
        .headers(
            "Cookie", "CloudFront-Policy=${cloudFrontCookieValue.policy}",
            "Cookie", "CloudFront-Signature=${cloudFrontCookieValue.signature}",
            "Cookie", "CloudFront-Key-Pair-Id=${cloudFrontCookieValue.keyPairId}"
        )
        .build()
    return client.send(request, responseBodyHandler)
}

data class CloudFrontSignedCookieValue(
    val policy: String,
    val signature: String,
    val keyPairId: String
) {
    companion object {
        fun create(policyStatement: String, key: CloudFrontKey): CloudFrontSignedCookieValue {
            val canonicalPolicyStatement = Regex("\\s").replace(policyStatement, "")
            val encodedPolicyStatement = encodeForCookieValue(canonicalPolicyStatement.toByteArray())
            val encodedSignature = sign(canonicalPolicyStatement.toByteArray(), key.privateKey)
            return CloudFrontSignedCookieValue(encodedPolicyStatement, encodedSignature, key.keyPairId)
        }

        private fun sign(base: ByteArray, privateKey: PrivateKey): String {
            val signatureInstance = Signature.getInstance("SHA1withRSA")
                // すべての Java 実装で SHA1withRSA がサポートされる。 Java SE 8 のドキュメントで確認。
            signatureInstance.initSign(privateKey)
            signatureInstance.update(base)
            val signature = signatureInstance.sign()
            return encodeForCookieValue(signature)
        }

        private fun encodeForCookieValue(value: ByteArray) =
            Base64.getEncoder().encodeToString(value)
                .replace('+', '-')
                .replace('=', '_')
                .replace('/', '~')
    }
}

data class CloudFrontKey(
    val keyPairId: String,
    val privateKey: PrivateKey
)

AWS のドキュメントには 『エンコーダが正常に機能するように、Bouncy Castle の Java 用暗号 API の jar をプロジェクトに追加してから Bouncy Castle プロバイダを追加します。』 と書かれているが、Java SE に含まれるエンコーダだけで問題なく署名できるはず。

AWS SDK for Java

AWS SDK for JavaCloudFrontCookieSigner というクラスがあり、これを使うことで signed cookie を生成することも可能。 通常はこれを使うのが良さそう。