たくさんの自由帳

AWS Signature V4 を Kotlin Multiplatform で作る

投稿日 : | 0 日前

文字数(だいたい) : 6709

どうもこんにちわ。
お土産をもらいました。外装がもうそれっぽい。

お土産

お土産

おいしかったです!!
ホワイトチョコのが一番かな。

お土産

本題

AWSってCDKAWS API クライアント)がない環境(Kotlin Multiplatform、君だよ~~~)でもクライアントが使えるように、REST APIを提供してます。
KotlinにもCDKありますが、JVMだけっぽい、そんな。

じゃあcurlHTTP クライアントで手軽に叩けるかというとそうではなく、
AWS SigV4 署名と呼ばれる文字列を作り、リクエストヘッダーにくっつける必要があります。。。

Imgur

Authorization: AWS4-HMAC-SHA256 Credential=....といった感じで、意味深なリクエストヘッダーがついています。
というわけで、今回はこの文字列を作ってみようと思います。
Kotlin Multiplatformに対応したライブラリを使うことで、Java 標準ライブラリに頼ることなく作成できます

公式

https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/reference_sigv-create-signed-request.html

説明用に

今回は解説のために、以下のパラメーター(アクセスキー)などはこれを使うことにします。
例示用のアクセスキーとかs3バケットないのかな、

なまえあたい
叩く URL の例https://s3.ap-northeast-1.amazonaws.com/myBucket/?list-type=2
HTTP メソッドGET
リージョンap-northeast-1
バケット名myBucket
アクセスキーAKIA0000
シークレットアクセスキー0000
x-amz-date の値20250507T164812Z
yyyyMMdd した日付フォーマット20250507
サービスs3

つくる

必要なライブラリ

もしKotlin MultiplatformAWS SigV4 署名を作る場合、Javaの標準ライブラリは使えない(Android/JVM以外で動かない)
ので、代替を使う必要があります。それがこれらです。

HTTP ClientAWSCDK無しのAPIで操作したいなら入ってるはずなので説明は省きます。(androidMainに書いてあるのはAndroidだと必要なので)
あとURLをパースしたり(ホスト/クエリパラメータの部分の抽出)、URLエンコードの目的にも使います。

SHA256HMAC-SHA256を計算できるマルチプラットフォーム対応ライブラリ、
kotlinx-datetimeはクロスプラットフォームの日付操作のためのライブラリです。

androidMain.dependencies {
    // 省略
 
    // Ktor Android Impl
    implementation("io.ktor:ktor-client-okhttp:3.1.2")
}
 
commonMain.dependencies {
    // 省略
 
    // HTTP Client
    implementation("io.ktor:ktor-client-core:3.1.2")
 
    // calc Hash
    implementation(platform("org.kotlincrypto.hash:bom:0.7.0"))
    implementation("org.kotlincrypto.hash:sha2")
 
    // calc Hmac-Hadh
    implementation(platform("org.kotlincrypto.macs:bom:0.7.0"))
    implementation("org.kotlincrypto.macs:hmac-sha2")
 
    // kotlinx.datetime
    implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2")
}

用意する拡張関数

今の時間をフォーマットする関数や、ハッシュ値を出す関数なんかはよく使う or 外からも呼び出して使う予定なのでこんな感じに。

/** ISO8601 */
@OptIn(FormatStringsInDatetimeFormats::class)
private val amzDateFormat = DateTimeComponents.Format {
    byUnicodePattern("""yyyyMMdd'T'HHmmss'Z'""")
}
 
/** yyyyMMdd */
@OptIn(FormatStringsInDatetimeFormats::class)
private val yearMonthDayDateFormat = DateTimeComponents.Format {
    byUnicodePattern("yyyyMMdd")
}
 
/** ISO8601 の形式でフォーマットする */
internal fun Instant.formatAmzDateString(): String {
    return this.format(amzDateFormat)
}
 
/** yyyy/MM/dd の形式でフォーマットする */
internal fun Instant.formatYearMonthDayDateString(): String {
    return this.format(yearMonthDayDateFormat)
}
 
/** バイト配列から SHA-256 */
internal fun ByteArray.sha256(): ByteArray {
    return SHA256().digest(this)
}
 
/** Java の putIfAbsent 相当 */
private fun <K, V> HashMap<K, V>.putIfAbsent(key: K, value: V): V? {
    var v = this.get(key)
    if (v == null) {
        v = put(key, value)
    }
    return v
}
 
/** 文字列から SHA-256 */
private fun String.sha256(): ByteArray {
    return this.toByteArray(Charsets.UTF_8).sha256()
}
 
/**
 * HMAC-SHA256 を計算
 * this がキーです
 */
private fun ByteArray.hmacSha256(message: String): ByteArray {
    val secretKey = this
    return HmacSHA256(secretKey).doFinal(message.toByteArray(Charsets.UTF_8))
}

関数

引数です。この中を埋めていきます。

@OptIn(ExperimentalStdlibApi::class)
internal fun generateAwsSign(
    url: String,
    httpMethod: String = "GET",
    contentType: String? = null,
    region: String = "ap-northeast-1",
    service: String = "s3",
    amzDateString: String,
    yyyyMMddString: String,
    secretAccessKey: String,
    accessKey: String,
    requestHeader: HashMap<String, String> = hashMapOf(),
    payloadSha256: String = "".sha256().toHexString()
): String {
    // このあとすぐ
}

前座 リクエストヘッダーを更新

足りないパラメーターがあれば、署名を作成する前に追加します。

val httpUrl = Url(urlString = url)
 
// リクエストヘッダーになければ追加
requestHeader.putIfAbsent("x-amz-date", amzDateString)
requestHeader.putIfAbsent("host", httpUrl.host)
if (contentType != null) {
    requestHeader.putIfAbsent("Content-Type", contentType)
}
requestHeader.putIfAbsent("x-amz-content-sha256", payloadSha256)

1正規リクエストを作成する

https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/reference_sigv-create-signed-request.html#create-canonical-request

形式はこれです。各値を\nで連結する形。
以下の形式はわかりやすく\nの後に改行を入れてますが、文字列を作る際は\nだけでいいです。改行しないでください。

${HTTPMethod}\n
${CanonicalURI}\n
${CanonicalQueryString}\n
${CanonicalHeaders}\n
${SignedHeaders}\n
${HashedPayload}
  • HTTPMethodGETPUTPOSTなどのやつ
  • CanonicalURIは、ドメインの後から、クエリパラメータの直前までです。
    • https://example.com/bucketName/?list-type=2なら、/bucketName/
    • URLエンコードしてください
  • CanonicalQueryStringは、クエリパラメータを繋げて文字列にするものですが、ルールがあります
    • URLエンコードしてください
    • クエリパラメータの名前でソートする。(アルファベット順)
      • ソートする際はURLエンコード後の名前を使う必要があります
    • 名前と値は=で連結し、クエリパラメータ同士は&で連結する
  • CanonicalHeadersは追加するリクエストヘッダーを繋げたものです
    • リクエストヘッダーの名前と値は小文字に揃える必要があります(lowerCase()
    • 同様に名前でソートしてください(アルファベット順)
    • 名前と値は : で連結して、連結した同士は\n(改行)で繋げます。
      • =じゃない!(一敗)
    • 一番最後にも \n が必要です。
  • SignedHeadersはリクエストヘッダーの名前を繋げてください
    • 同様に小文字に揃えて、アルファベット順で並び替えます
    • 名前同士は;で連結してください
  • HashedPayloadは、リクエストボディをSHA-256した値です。
    • GETの場合等、ボディーない場合は""(空文字)をSHA-256したものを入れてください
// 1.正規リクエストを作成する
// パス、クエリパラメータは URL エンコードする
// リスト系はアルファベットでソート
val canonicalUri = httpUrl.encodedPath.encodeURLPath().ifBlank { "/" }
val canonicalQueryString = httpUrl.parameters
    .names()
    .map { it.encodeURLParameter() }
    .sortedBy { name -> name }
    .associateWith { name -> httpUrl.parameters[name]?.encodeURLParameter() }
    .toList()
    .joinToString(separator = "&") { (name, values) ->
        "$name=${values ?: ""}" // こっちはイコール
    }
val canonicalHeaders = requestHeader
    .toList()
    .sortedBy { (name, _) -> name.lowercase() }
    .joinToString(separator = "\n") { (name, value) ->
        "${name.lowercase()}:${value.trim()}"
    } + "\n" // 末尾改行で終わる
val signedHeaders = requestHeader
    .toList()
    .map { (name, _) -> name.lowercase() }
    .sorted()
    .joinToString(separator = ";")
val hashedPayload = payloadSha256.lowercase()
val canonicalRequest = httpMethod + "\n" + canonicalUri + "\n" + canonicalQueryString + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + hashedPayload

うまくいくとcanonicalRequestはこんな文字列になるはずです。
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855は空のSHA2

GET
/myBucket/
list-type=2
host:s3.ap-northeast-1.amazonaws.com
x-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
x-amz-date:20250507T164812Z
 
host;x-amz-content-sha256;x-amz-date
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

2正規リクエストのハッシュを作成する

https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/reference_sigv-create-signed-request.html#create-canonical-request-hash

HashedPayloadと同じハッシュ関数(SHA-256)で、↑で作ったcanonicalRequestをハッシュ化します。
文字列の16進数を取得してください。

// 2.正規リクエストのハッシュを作成する。ペイロードと同じハッシュ関数
val hashedCanonicalRequest = canonicalRequest.sha256().toHexString()
ac5c69c03c2cb898197213a13ccb017423f4bc733b6912f3c75945f473387060

3署名文字列を作成する

https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/reference_sigv-create-signed-request.html#create-string-to-sign

形式はこれです。各値を\nで連結する形。
先述の通りですが、わかりやすさのために\nの後に改行を入れてますが、文字列を作る際は改行無しで\nのみでよいです。

${Algorithm}\n
${RequestDateTime}\n
${CredentialScope}\n
${HashedCanonicalRequest}
  • Algorithmですが、これはAWS4-HMAC-SHA256でよいはず
  • RequestDateTimeISO8601形式の今の時間です
    • x-amz-dateのリクエストヘッダーの値を入れればよいです
  • CredentialScopeは、以下の文字列の形式です
    • ${yyyyMMdd}/${region}/${service}/aws4_request
    • yyyyMMddには今の時間をyyyy/MM/dd形式にフォーマットしたものを入れてください(SimpleDateFormat()は、、おじいちゃんか)
    • regionにはAWSのリージョンを
    • services3とかec2が入ります
    • 最後のaws4_requestは固定らしいです
  • HashedCanonicalRequestは、手順2でやった、hashedCanonicalRequestを入れてください。
// 3.署名文字列を作成する
val algorithm = "AWS4-HMAC-SHA256"
val requestDateTime = amzDateString
val credentialScope = "$yyyyMMddString/$region/$service/aws4_request"
val stringToSign = algorithm + "\n" + requestDateTime + "\n" + credentialScope + "\n" + hashedCanonicalRequest

うまくいくと、こんな文字列になるはずです。

AWS4-HMAC-SHA256
20250507T164812Z
20250507/ap-northeast-1/s3/aws4_request
ac5c69c03c2cb898197213a13ccb017423f4bc733b6912f3c75945f473387060

4SigV4の署名キーの取得

https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/reference_sigv-create-signed-request.html#derive-signing-key

ここでシークレットアクセスキーがやっと登場する形になります。

これはもうコード貼ったほうが早いのでそうします。説明できる気がしない。
ここでHMAC-SHA256が登場します。前回のハッシュ値を使って、次のハッシュ値の計算をする複雑なやつ。

  • 1.AWS4$secretAccessKeyをバイト配列にしたものをキー、今の時間をyyyy/MM/ddでフォーマットしたものを値にして、HMAC-SHA256を計算
  • 2.1で出したハッシュをキー、AWSのリージョンを値として、HMAC-SHA256を計算
  • 3.2で出したハッシュをキー、サービス(s3とか)を値として、HMAC-SHA256を計算
  • 4.3で出したハッシュをキー、aws4_requestの文字列を値として、HMAC-SHA256を計算
// 4.SigV4 の署名キーの取得
val dateKey = "AWS4$secretAccessKey".toByteArray(Charsets.UTF_8).hmacSha256(message = yyyyMMddString)
val dateRegionKey = dateKey.hmacSha256(message = region)
val dateRegionServiceKey = dateRegionKey.hmacSha256(message = service)
val signingKey = dateRegionServiceKey.hmacSha256(message = "aws4_request")

このsigningKey16進数文字列にした結果がこれになってるはず?

8645308c3a6e25e207681d29e27240eaa62140ce3624a719be42f005a3225bfe

5署名を計算する

https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/reference_sigv-create-signed-request.html#calculate-signature

signingKeyをキー、手順3で作ったstringToSignを値として、HMAC-SHA256を計算。
これを16進数文字列にする。小文字は念のため呼んでいる。

// 5.署名を計算する
val signature = signingKey.hmacSha256(message = stringToSign).toHexString().lowercase()

多分こんな文字列になるはずです。

d0feff0891c0ca4a27641bce11ac1e1ec60f0380c5a6d72cad42f53fb86061b9

6リクエストヘッダーにつける文字列を完成させる

https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/reference_sigv-create-signed-request.html#add-signature-to-request

Authorization: ${ここの文字列}を作ります。
これも文字列を連結させるのですが、こうです。

AWS4-HMAC-SHA256 Credential=${accessKey}/${credentialScope},SignedHeaders=${signedHeaders},Signature=${signature}
  • accessKeyがアクセスキー、シークレットアクセスキーと対になっているあれ
  • credentialScopeは手順3で作ったものを使えばよいです
  • signedHeadersも手順1で作ったものを使えばよいです
  • signatureは手順5で作ったものになります!
// 6.リクエストヘッダーに署名を追加する
val authorizationHeaderValue = algorithm + " " + "Credential=$accessKey/$credentialScope" + "," + "SignedHeaders=$signedHeaders" + "," + "Signature=$signature"
return authorizationHeaderValue

こんな文字列になるはずです。

AWS4-HMAC-SHA256 Credential=AKIA0000/20250507/ap-northeast-1/s3/aws4_request,SignedHeaders=host;x-amz-content-sha256;x-amz-date,Signature=d0feff0891c0ca4a27641bce11ac1e1ec60f0380c5a6d72cad42f53fb86061b9

ここまで

@OptIn(ExperimentalStdlibApi::class)
internal fun generateAwsSign(
    url: String,
    httpMethod: String = "GET",
    contentType: String? = null,
    region: String = "ap-northeast-1",
    service: String = "s3",
    amzDateString: String,
    yyyyMMddString: String,
    secretAccessKey: String,
    accessKey: String,
    requestHeader: HashMap<String, String> = hashMapOf(),
    payloadSha256: String = "".sha256().toHexString()
): String {
    val httpUrl = Url(urlString = url)
 
    // リクエストヘッダーになければ追加
    requestHeader.putIfAbsent("x-amz-date", amzDateString)
    requestHeader.putIfAbsent("host", httpUrl.host)
    if (contentType != null) {
        requestHeader.putIfAbsent("Content-Type", contentType)
    }
    requestHeader.putIfAbsent("x-amz-content-sha256", payloadSha256)
 
    // 1.正規リクエストを作成する
    // パス、クエリパラメータは URL エンコードする
    // リスト系はアルファベットでソート
    val canonicalUri = httpUrl.encodedPath.encodeURLPath().ifBlank { "/" }
    val canonicalQueryString = httpUrl.parameters
        .names()
        .map { it.encodeURLParameter() }
        .sortedBy { name -> name }
        .associateWith { name -> httpUrl.parameters[name]?.encodeURLParameter() }
        .toList()
        .joinToString(separator = "&") { (name, values) ->
            "$name=${values ?: ""}" // こっちはイコール
        }
    val canonicalHeaders = requestHeader
        .toList()
        .sortedBy { (name, _) -> name.lowercase() }
        .joinToString(separator = "\n") { (name, value) ->
            "${name.lowercase()}:${value.trim()}"
        } + "\n" // 末尾改行で終わる
    val signedHeaders = requestHeader
        .toList()
        .map { (name, _) -> name.lowercase() }
        .sorted()
        .joinToString(separator = ";")
    val hashedPayload = payloadSha256.lowercase()
    val canonicalRequest = httpMethod + "\n" + canonicalUri + "\n" + canonicalQueryString + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + hashedPayload
 
    // 2.正規リクエストのハッシュを作成する。ペイロードと同じハッシュ関数
    val hashedCanonicalRequest = canonicalRequest.sha256().toHexString()
 
    // 3.署名文字列を作成する
    val algorithm = "AWS4-HMAC-SHA256"
    val requestDateTime = amzDateString
    val credentialScope = "$yyyyMMddString/$region/$service/aws4_request"
    val stringToSign = algorithm + "\n" + requestDateTime + "\n" + credentialScope + "\n" + hashedCanonicalRequest
 
    // 4.SigV4 の署名キーの取得
    val dateKey = "AWS4$secretAccessKey".toByteArray(Charsets.UTF_8).hmacSha256(message = yyyyMMddString)
    val dateRegionKey = dateKey.hmacSha256(message = region)
    val dateRegionServiceKey = dateRegionKey.hmacSha256(message = service)
    val signingKey = dateRegionServiceKey.hmacSha256(message = "aws4_request")
 
    // 5.署名を計算する
    val signature = signingKey.hmacSha256(message = stringToSign).toHexString().lowercase()
 
    // 6.リクエストヘッダーに署名を追加する
    val authorizationHeaderValue = algorithm + " " + "Credential=$accessKey/$credentialScope" + "," + "SignedHeaders=$signedHeaders" + "," + "Signature=$signature"
    return authorizationHeaderValue
}

使ってみる

Kotlin MultiplatformComposeの画面で実行してみます。
手始めにバケットの中身を取得するListObjectsV2 APIを叩いてみようと思います。
https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html

// TODO 皆さんそれぞれ設定してください!!!
const val bucketName = ""
const val region = "ap-northeast-1"
const val secretAccessKey = ""
const val accessKey = ""
 
/** Kotlin Multiplatform Compose */
@Composable
@Preview
fun App() {
    MaterialTheme {
 
        // バケットの中身を取得する REST API を叩く
        val responseXml = remember { mutableStateOf("") }
        LaunchedEffect(key1 = Unit) {
            val now = Clock.System.now()
            val url = "https://s3.$region.amazonaws.com/$bucketName/?list-type=2"
            val amzDateString = now.formatAmzDateString()
            val yyyyMMddString = now.formatYearMonthDayDateString()
 
            // 署名を作成
            val requestHeader = hashMapOf(
                "x-amz-date" to amzDateString,
                "host" to "s3.$region.amazonaws.com"
            )
            val signature = generateAwsSign(
                url = url,
                httpMethod = "GET",
                contentType = null,
                region = region,
                service = "s3",
                amzDateString = amzDateString,
                yyyyMMddString = yyyyMMddString,
                secretAccessKey = secretAccessKey,
                accessKey = accessKey,
                requestHeader = requestHeader
            )
 
            // レスポンス xml を取得
            val httpClient = HttpClient()
            val response = httpClient.get {
                url(url)
                headers {
                    // 署名をリクエストヘッダーにつける
                    requestHeader.forEach { (name, value) ->
                        this[name] = value
                    }
                    this["Authorization"] = signature
                }
            }
            responseXml.value = response.bodyAsText()
        }
 
 
        var showContent by remember { mutableStateOf(false) }
        Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
            Button(onClick = { showContent = !showContent }) {
                Text("Click me!")
            }
            AnimatedVisibility(showContent) {
                val greeting = remember { Greeting().greet() }
                Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
                    Image(painterResource(Res.drawable.compose_multiplatform), null)
                    Text("Compose: $greeting")
                }
            }
 
            Text(text = responseXml.value)
        }
    }
}

実行してみると、AndroidでもWebブラウザ (Wasm)でも表示できているはずです。Kotlin Multiplatform で AWS 出来ましたね!!!
iOSmacがなくわかりません。。。

あ、もしWebブラウザ (Wasm)でリクエストしたい場合は、S3バケットのCORS設定を変更する必要があります。
https://docs.aws.amazon.com/ja_jp/sdk-for-javascript/v2/developer-guide/s3-example-photo-album.html#s3-example-photo-album-cors-configuration

すげー、ちゃんとマルチプラットフォームだ。。。

Imgur

ソースコード

https://github.com/takusan23/MultiplatformAwsSignV4

おわりに1

https://github.com/lucasweb78/aws-v4-signer-java

大変参考になりました。

おわりに2

超絶どうでもいい話ですが、

CloudflareもオブジェクトストレージとしてR2ってのを提供しているけど、
名前の由来がAWSS3から一文字ずつずらしたって話すき。