たくさんの自由帳
Androidのお話
たくさんの自由帳
投稿日 : | 0 日前
文字数(だいたい) : 6709
どうもこんにちわ。
お土産をもらいました。外装がもうそれっぽい。
おいしかったです!!
ホワイトチョコのが一番かな。
AWSってCDK(AWS API クライアント)がない環境(Kotlin Multiplatform、君だよ~~~)でもクライアントが使えるように、REST APIを提供してます。KotlinにもCDKありますが、JVMだけっぽい、そんな。
じゃあcurlやHTTP クライアントで手軽に叩けるかというとそうではなく、AWS SigV4 署名と呼ばれる文字列を作り、リクエストヘッダーにくっつける必要があります。。。
Authorization: AWS4-HMAC-SHA256 Credential=....といった感じで、意味深なリクエストヘッダーがついています。
というわけで、今回はこの文字列を作ってみようと思います。Kotlin Multiplatformに対応したライブラリを使うことで、Java 標準ライブラリに頼ることなく作成できます
今回は解説のために、以下のパラメーター(アクセスキー)などはこれを使うことにします。
例示用のアクセスキーとか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 MultiplatformでAWS SigV4 署名を作る場合、Javaの標準ライブラリは使えない(Android/JVM以外で動かない)
ので、代替を使う必要があります。それがこれらです。
HTTP ClientはAWSをCDK無しのAPIで操作したいなら入ってるはずなので説明は省きます。(androidMainに書いてあるのはAndroidだと必要なので)
あとURLをパースしたり(ホスト/クエリパラメータの部分の抽出)、URLエンコードの目的にも使います。
SHA256とHMAC-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)形式はこれです。各値を\nで連結する形。
以下の形式はわかりやすく\nの後に改行を入れてますが、文字列を作る際は\nだけでいいです。改行しないでください。
${HTTPMethod}\n
${CanonicalURI}\n
${CanonicalQueryString}\n
${CanonicalHeaders}\n
${SignedHeaders}\n
${HashedPayload}HTTPMethodはGETやPUT、POSTなどのやつCanonicalURIは、ドメインの後から、クエリパラメータの直前までです。
https://example.com/bucketName/?list-type=2なら、/bucketName/URLエンコードしてくださいCanonicalQueryStringは、クエリパラメータを繋げて文字列にするものですが、ルールがあります
URLエンコードしてくださいURLエンコード後の名前を使う必要があります=で連結し、クエリパラメータ同士は&で連結するCanonicalHeadersは追加するリクエストヘッダーを繋げたものです
lowerCase())\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
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855HashedPayloadと同じハッシュ関数(SHA-256)で、↑で作ったcanonicalRequestをハッシュ化します。
文字列の16進数を取得してください。
// 2.正規リクエストのハッシュを作成する。ペイロードと同じハッシュ関数
val hashedCanonicalRequest = canonicalRequest.sha256().toHexString()ac5c69c03c2cb898197213a13ccb017423f4bc733b6912f3c75945f473387060形式はこれです。各値を\nで連結する形。
先述の通りですが、わかりやすさのために\nの後に改行を入れてますが、文字列を作る際は改行無しで\nのみでよいです。
${Algorithm}\n
${RequestDateTime}\n
${CredentialScope}\n
${HashedCanonicalRequest}Algorithmですが、これはAWS4-HMAC-SHA256でよいはずRequestDateTimeはISO8601形式の今の時間です
x-amz-dateのリクエストヘッダーの値を入れればよいですCredentialScopeは、以下の文字列の形式です
${yyyyMMdd}/${region}/${service}/aws4_requestyyyyMMddには今の時間をyyyy/MM/dd形式にフォーマットしたものを入れてください(SimpleDateFormat()は、、おじいちゃんか)regionにはAWSのリージョンをserviceはs3とか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ここでシークレットアクセスキーがやっと登場する形になります。
これはもうコード貼ったほうが早いのでそうします。説明できる気がしない。
ここでHMAC-SHA256が登場します。前回のハッシュ値を使って、次のハッシュ値の計算をする複雑なやつ。
AWS4$secretAccessKeyをバイト配列にしたものをキー、今の時間をyyyy/MM/ddでフォーマットしたものを値にして、HMAC-SHA256を計算1で出したハッシュをキー、AWSのリージョンを値として、HMAC-SHA256を計算2で出したハッシュをキー、サービス(s3とか)を値として、HMAC-SHA256を計算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")このsigningKeyを16進数文字列にした結果がこれになってるはず?
8645308c3a6e25e207681d29e27240eaa62140ce3624a719be42f005a3225bfesigningKeyをキー、手順3で作ったstringToSignを値として、HMAC-SHA256を計算。
これを16進数文字列にする。小文字は念のため呼んでいる。
// 5.署名を計算する
val signature = signingKey.hmacSha256(message = stringToSign).toHexString().lowercase()多分こんな文字列になるはずです。
d0feff0891c0ca4a27641bce11ac1e1ec60f0380c5a6d72cad42f53fb86061b9Authorization: ${ここの文字列}を作ります。
これも文字列を連結させるのですが、こうです。
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 MultiplatformのComposeの画面で実行してみます。ListObjectsV2 APIを叩いてみようと思います。// 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 出来ましたね!!!iOSはmacがなくわかりません。。。
Webブラウザ (Wasm)でリクエストしたい場合は、S3バケットのCORS設定を変更する必要があります。すげー、ちゃんとマルチプラットフォームだ。。。
大変参考になりました。
超絶どうでもいい話ですが、
CloudflareもオブジェクトストレージとしてR2ってのを提供しているけど、
名前の由来がAWSのS3から一文字ずつずらしたって話すき。