Skip to content

Instantly share code, notes, and snippets.

@luca992
Last active November 12, 2023 00:39
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save luca992/969af9735c71a284128ba91623741efa to your computer and use it in GitHub Desktop.
Save luca992/969af9735c71a284128ba91623741efa to your computer and use it in GitHub Desktop.
Kotlin Multiplatform S3 Request
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.datetime.Clock
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import okio.ByteString.Companion.encodeUtf8
/**
* https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html
*/
suspend fun s3Request(
httpReqMethod: String = "PUT",
fileName: String,
bodyBytes: ByteArray? = null,
contentType: ContentType? = null,
bucket: String,
region: String = "us-east-1",
domain: String = "amazonaws.com",
awsAccess: String,
awsSecret: String
): HttpResponse {
val authType = "AWS4-HMAC-SHA256"
val service = "s3"
val baseUrl = ".$service.$domain"
val now = Clock.System.now().toLocalDateTime(TimeZone.UTC)
val dateValueS = getDateValueS(now)
val dateValueL = getDateValueL(now)
val payloadHash = "UNSIGNED-PAYLOAD"//(bodyBytes ?: "".toByteArray()).toByteString().sha256().hex()
// should be sorted by header name
val canonicalHeaders = mapOf(
// "Content-Type" to contentType!!.toString(),
"host" to "$bucket$baseUrl",
// "x-amz-content-sha256" to payloadHash,
)
val headerKeys = canonicalHeaders.mapKeys { it.key.lowercase() }.keys.joinToString(";")
// should be sorted by param name
val canonicalQueryParams = mapOf(
"X-Amz-Algorithm" to authType,
"X-Amz-Credential" to "${awsAccess}/${dateValueS}/${region}/${service}/aws4_request",
"X-Amz-Date" to dateValueL,
"X-Amz-Expires" to "86400",
"X-Amz-SignedHeaders" to headerKeys
// "X-Amz-Signature" will be added after generating the signature
)
val canonicalRequest = buildString {
append("$httpReqMethod\n")
append("/$fileName\n")
append(canonicalQueryParams.entries.joinToString("&") { "${it.key.encodeURLParameter()}=${it.value.encodeURLParameter()}" })
append("\n")
canonicalHeaders.forEach { (key, value) ->
append("${key.lowercase()}:${value.trim()}\n")
}
append("\n")
append(headerKeys + "\n")
append(payloadHash)
}
val canonicalRequestHash = canonicalRequest.encodeUtf8().sha256().hex()
val stringToSign = "$authType\n$dateValueL\n$dateValueS/$region/$service/aws4_request\n$canonicalRequestHash"
val kSecret = "AWS4$awsSecret".encodeUtf8()
val kDate = dateValueS.encodeUtf8().hmacSha256(kSecret)
val kRegion = region.encodeUtf8().hmacSha256(kDate)
val kService = service.encodeUtf8().hmacSha256(kRegion)
val kSigning = "aws4_request".encodeUtf8().hmacSha256(kService)
val signature = stringToSign.encodeUtf8().hmacSha256(kSigning)
val client = HttpClient {}
return client.request {
method = HttpMethod.parse(httpReqMethod)
url {
host = "${bucket}${baseUrl}"
protocol = URLProtocol.HTTPS
path(fileName)
bodyBytes?.run { setBody(this) }
canonicalQueryParams.forEach { (key, value) ->
parameters.append(key, value)
}
parameters.append("X-Amz-Signature", signature.hex())
}
contentType?.let { contentType(it) }
}
}
private fun getDateValueS(dateTime: LocalDateTime): String {
return buildString {
append(dateTime.year.toString().padStart(4, '0'))
append(dateTime.monthNumber.toString().padStart(2, '0'))
append(dateTime.dayOfMonth.toString().padStart(2, '0'))
}
}
private fun getDateValueL(dateTime: LocalDateTime): String {
return buildString {
append(getDateValueS(dateTime))
append('T')
append(dateTime.hour.toString().padStart(2, '0'))
append(dateTime.minute.toString().padStart(2, '0'))
append(dateTime.second.toString().padStart(2, '0'))
append('Z')
}
}
@luca992
Copy link
Author

luca992 commented Nov 12, 2023

PUT and GET have been tested

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment