Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e7a63f4
fix event stream filtering
aajtodd Oct 28, 2021
90ffcd7
add parsing of common headers
aajtodd Oct 29, 2021
ab7cb9a
refractor frame decoder into a Flow
aajtodd Oct 29, 2021
219e38c
remove event stream operation filter from customizations
aajtodd Nov 1, 2021
64734c6
Merge remote-tracking branch 'origin/main' into event-stream-poc
aajtodd Dec 6, 2021
9f83729
Merge remote-tracking branch 'origin/main' into event-stream-poc
aajtodd Jan 21, 2022
f26ed77
Merge remote-tracking branch 'origin/main' into feat-event-stream
aajtodd Feb 8, 2022
3d8b101
refactore event stream parsing; implement rough deserialization codegen
aajtodd Feb 10, 2022
54deef6
Merge remote-tracking branch 'origin/main' into feat-event-stream
aajtodd Feb 17, 2022
f80d912
fix warning
aajtodd Feb 17, 2022
80b4906
filter out event stream errors
aajtodd Feb 17, 2022
837d962
render deserialization for exception event stream messages
aajtodd Feb 17, 2022
1972fa9
inject http request signature into the execution context once known
aajtodd Feb 18, 2022
61513bc
add support for chunked signing
aajtodd Feb 18, 2022
b857b12
add encode transform for message stream
aajtodd Feb 18, 2022
5821d0c
inline signing config builder
aajtodd Feb 18, 2022
1f535e8
initial event stream serialize implementation
aajtodd Feb 22, 2022
b98f700
fix compile issues
aajtodd Feb 22, 2022
77e43a0
disable wip integration tests
aajtodd Feb 23, 2022
4e24020
Merge branch 'feat-event-streams' into bootstrap-event-streams
aajtodd Feb 23, 2022
c82e218
suppress test; cleanup codegen
aajtodd Feb 24, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions aws-runtime/aws-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ kotlin {
commonMain {
dependencies {
api("aws.smithy.kotlin:runtime-core:$smithyKotlinVersion")

// FIXME - should we just move these into core and get rid of aws-types at this point?
api(project(":aws-runtime:aws-types"))
Comment on lines +19 to +20
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment: Yes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll do it either in one of the follow on PRs or separately after event streams is complete

implementation("aws.smithy.kotlin:logging:$smithyKotlinVersion")
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@

package aws.sdk.kotlin.runtime.execution

import aws.sdk.kotlin.runtime.auth.credentials.CredentialsProvider
import aws.smithy.kotlin.runtime.client.ClientOption
import aws.smithy.kotlin.runtime.time.Instant
import aws.smithy.kotlin.runtime.util.AttributeKey
import aws.smithy.kotlin.runtime.util.InternalApi

/**
* Operation (execution) options related to authorization
Expand Down Expand Up @@ -34,4 +37,17 @@ public object AuthAttributes {
* NOTE: This is not a common option.
*/
public val SigningDate: ClientOption<Instant> = ClientOption("SigningDate")

/**
* The [CredentialsProvider] to complete the signing process with. Defaults to the provider configured
* on the service client.
* NOTE: This is not a common option.
*/
public val CredentialsProvider: ClientOption<CredentialsProvider> = ClientOption("CredentialsProvider")
Comment on lines +41 to +46
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: These attributes are intended to be used by end-users? If so, can we offer better guidance than "This is not a common option"? Maybe something like "Changing this option is only required in advanced use cases".

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure we can change it. I'm not convinced all of these are intended for use by end-users but I also don't see a reason why they couldn't be. We need to spend some time looking at per/operation config and interceptors. Originally the execution context was envisioned to be something that end users may influence directly but currently they have no way of accessing it.


/**
* The signature of the HTTP request. This will only exist after the request has been signed!
*/
@InternalApi
public val RequestSignature: AttributeKey<ByteArray> = AttributeKey("AWS_HTTP_SIGNATURE")
}
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,12 @@ public class AwsSigV4SigningMiddleware(private val config: Config) : ModifyReque
}
}

val signedRequest = AwsSigner.signRequest(signableRequest, opSigningConfig.toCrt())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Does AwsSigner.signRequest still need to exist?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you asking should it still exist in aws-crt-kotlin?

Probably not if the sign() function returning AwsSigningResult supersedes it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, asking about the implementation in aws-crt-kotlin specifically. I didn't see any remaining usage after this removal.

val signingResult = AwsSigner.sign(signableRequest, opSigningConfig.toCrt())
val signedRequest = checkNotNull(signingResult.signedRequest) { "signing result must return a non-null HTTP request" }

// Add the signature to the request context
req.context[AuthAttributes.RequestSignature] = signingResult.signature

req.subject.update(signedRequest)
req.subject.body.resetStream()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
package aws.sdk.kotlin.runtime.auth.signing

import aws.sdk.kotlin.crt.auth.signing.AwsSigner
import aws.sdk.kotlin.runtime.InternalSdkApi
import aws.sdk.kotlin.runtime.crt.toSignableCrtRequest
import aws.sdk.kotlin.runtime.crt.update
import aws.smithy.kotlin.runtime.http.request.HttpRequest
Expand Down Expand Up @@ -61,3 +62,18 @@ public suspend fun sign(request: HttpRequest, config: AwsSigningConfig): Signing
val output = builder.build()
return SigningResult(output, crtResult.signature)
}

/**
* Sign a body [chunk] using the given signing [config]
*
* @param chunk the body chunk to sign
* @param prevSignature the signature of the previous component of the request (either the initial request signature
* itself for the first chunk or the previous chunk otherwise)
* @param config the signing configuration to use
* @return the signing result
*/
@InternalSdkApi
public suspend fun sign(chunk: ByteArray, prevSignature: ByteArray, config: AwsSigningConfig): SigningResult<Unit> {
val crtResult = AwsSigner.signChunk(chunk, prevSignature, config.toCrt())
return SigningResult(Unit, crtResult.signature)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

package aws.sdk.kotlin.runtime.auth.signing

import aws.sdk.kotlin.runtime.InternalSdkApi
import aws.sdk.kotlin.runtime.auth.credentials.Credentials
import aws.sdk.kotlin.runtime.auth.credentials.CredentialsProvider
import aws.smithy.kotlin.runtime.time.Instant
Expand All @@ -21,7 +22,7 @@ public typealias ShouldSignHeaderFn = (String) -> Boolean
*/
public class AwsSigningConfig private constructor(builder: Builder) {
public companion object {
public operator fun invoke(block: Builder.() -> Unit): AwsSigningConfig = Builder().apply(block).build()
public inline operator fun invoke(block: Builder.() -> Unit): AwsSigningConfig = Builder().apply(block).build()
}
/**
* The region to sign against
Expand Down Expand Up @@ -119,6 +120,27 @@ public class AwsSigningConfig private constructor(builder: Builder) {
*/
public val expiresAfter: Duration? = builder.expiresAfter

@InternalSdkApi
public fun toBuilder(): Builder {
val config = this
return Builder().apply {
region = config.region
service = config.service
date = config.date
algorithm = config.algorithm
shouldSignHeader = config.shouldSignHeader
signatureType = config.signatureType
useDoubleUriEncode = config.useDoubleUriEncode
normalizeUriPath = config.normalizeUriPath
omitSessionToken = config.omitSessionToken
signedBodyValue = config.signedBodyValue
signedBodyHeader = config.signedBodyHeaderType
credentials = config.credentials
credentialsProvider = config.credentialsProvider
expiresAfter = config.expiresAfter
}
}

public class Builder {
public var region: String? = null
public var service: String? = null
Expand All @@ -135,7 +157,8 @@ public class AwsSigningConfig private constructor(builder: Builder) {
public var credentialsProvider: CredentialsProvider? = null
public var expiresAfter: Duration? = null

internal fun build(): AwsSigningConfig = AwsSigningConfig(this)
@InternalSdkApi
public fun build(): AwsSigningConfig = AwsSigningConfig(this)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@

package aws.sdk.kotlin.runtime.auth.signing

import aws.sdk.kotlin.runtime.auth.credentials.Credentials
import aws.smithy.kotlin.runtime.http.HttpMethod
import aws.smithy.kotlin.runtime.http.Url
import aws.smithy.kotlin.runtime.http.content.ByteArrayContent
import aws.smithy.kotlin.runtime.http.request.HttpRequest
import aws.smithy.kotlin.runtime.http.request.HttpRequestBuilder
import aws.smithy.kotlin.runtime.http.request.headers
import aws.smithy.kotlin.runtime.http.request.url
import aws.smithy.kotlin.runtime.time.Instant
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
Expand Down Expand Up @@ -83,4 +88,102 @@ class AwsSigningTest {
val authHeader = result.output.headers["Authorization"]!!
assertTrue(authHeader.contains(expectedPrefix), "Sigv4A auth header: $authHeader")
}

// based on: https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html#example-signature-calculations-streaming
private val CHUNKED_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"
private val CHUNKED_SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
private val CHUNKED_TEST_CREDENTIALS = Credentials(CHUNKED_ACCESS_KEY_ID, CHUNKED_SECRET_ACCESS_KEY)
private val CHUNKED_TEST_REGION = "us-east-1"
private val CHUNKED_TEST_SERVICE = "s3"
private val CHUNKED_TEST_SIGNING_TIME = "2013-05-24T00:00:00Z"
private val CHUNK1_SIZE = 65536
private val CHUNK2_SIZE = 1024

private val EXPECTED_CHUNK_REQUEST_AUTHORIZATION_HEADER =
"AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, " +
"SignedHeaders=content-encoding;content-length;host;x-amz-content-sha256;x-amz-date;x-amz-decoded-content-length;x-" +
"amz-storage-class, Signature=4f232c4386841ef735655705268965c44a0e4690baa4adea153f7db9fa80a0a9"

private val EXPECTED_REQUEST_SIGNATURE = "4f232c4386841ef735655705268965c44a0e4690baa4adea153f7db9fa80a0a9"
private val EXPECTED_FIRST_CHUNK_SIGNATURE = "ad80c730a21e5b8d04586a2213dd63b9a0e99e0e2307b0ade35a65485a288648"
private val EXPECTED_SECOND_CHUNK_SIGNATURE = "0055627c9e194cb4542bae2aa5492e3c1575bbb81b612b7d234b86a503ef5497"
private val EXPECTED_FINAL_CHUNK_SIGNATURE = "b6c6ea8a5354eaf15b3cb7646744f4275b71ea724fed81ceb9323e279d449df9"
private val EXPECTED_TRAILING_HEADERS_SIGNATURE = "df5735bd9f3295cd9386572292562fefc93ba94e80a0a1ddcbd652c4e0a75e6c"

private fun createChunkedRequestSigningConfig(): AwsSigningConfig = AwsSigningConfig {
algorithm = AwsSigningAlgorithm.SIGV4
signatureType = AwsSignatureType.HTTP_REQUEST_VIA_HEADERS
region = CHUNKED_TEST_REGION
service = CHUNKED_TEST_SERVICE
date = Instant.fromIso8601(CHUNKED_TEST_SIGNING_TIME)
useDoubleUriEncode = false
normalizeUriPath = true
signedBodyHeader = AwsSignedBodyHeaderType.X_AMZ_CONTENT_SHA256
signedBodyValue = AwsSignedBodyValue.STREAMING_AWS4_HMAC_SHA256_PAYLOAD
credentials = CHUNKED_TEST_CREDENTIALS
}

private fun createChunkedSigningConfig(): AwsSigningConfig = AwsSigningConfig {
algorithm = AwsSigningAlgorithm.SIGV4
signatureType = AwsSignatureType.HTTP_REQUEST_CHUNK
region = CHUNKED_TEST_REGION
service = CHUNKED_TEST_SERVICE
date = Instant.fromIso8601(CHUNKED_TEST_SIGNING_TIME)
useDoubleUriEncode = false
normalizeUriPath = true
signedBodyHeader = AwsSignedBodyHeaderType.NONE
credentials = CHUNKED_TEST_CREDENTIALS
}

private fun createChunkedTestRequest() = HttpRequest {
method = HttpMethod.PUT
url(Url.parse("https://s3.amazonaws.com/examplebucket/chunkObject.txt"))
headers {
set("Host", url.host)
set("x-amz-storage-class", "REDUCED_REDUNDANCY")
set("Content-Encoding", "aws-chunked")
set("x-amz-decoded-content-length", "66560")
set("Content-Length", "66824")
}
}

private fun chunk1(): ByteArray {
val chunk = ByteArray(CHUNK1_SIZE)
for (i in chunk.indices) {
chunk[i] = 'a'.code.toByte()
}
return chunk
}

private fun chunk2(): ByteArray {
val chunk = ByteArray(CHUNK2_SIZE)
for (i in chunk.indices) {
chunk[i] = 'a'.code.toByte()
}
return chunk
}

@Test
fun testSignChunks() = runTest {
val request = createChunkedTestRequest()
val chunkedRequestConfig = createChunkedRequestSigningConfig()
val requestResult = sign(request, chunkedRequestConfig)
assertEquals(EXPECTED_CHUNK_REQUEST_AUTHORIZATION_HEADER, requestResult.output.headers["Authorization"])
assertEquals(EXPECTED_REQUEST_SIGNATURE, requestResult.signature.decodeToString())

var prevSignature = requestResult.signature

val chunkedSigningConfig = createChunkedSigningConfig()
val chunk1Result = sign(chunk1(), prevSignature, chunkedSigningConfig)
assertEquals(EXPECTED_FIRST_CHUNK_SIGNATURE, chunk1Result.signature.decodeToString())
prevSignature = chunk1Result.signature

val chunk2Result = sign(chunk2(), prevSignature, chunkedSigningConfig)
assertEquals(EXPECTED_SECOND_CHUNK_SIGNATURE, chunk2Result.signature.decodeToString())
prevSignature = chunk2Result.signature

// TODO - do we want 0 byte data like this or just allow null?
val finalChunkResult = sign(ByteArray(0), prevSignature, chunkedSigningConfig)
assertEquals(EXPECTED_FINAL_CHUNK_SIGNATURE, finalChunkResult.signature.decodeToString())
}
}
10 changes: 10 additions & 0 deletions aws-runtime/protocols/aws-event-stream/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,27 @@ description = "Support for the vnd.amazon.event-stream content type"
extra["displayName"] = "AWS :: SDK :: Kotlin :: Protocols :: Event Stream"
extra["moduleName"] = "aws.sdk.kotlin.runtime.protocol.eventstream"

val smithyKotlinVersion: String by project
val coroutinesVersion: String by project
kotlin {
sourceSets {
commonMain {
dependencies {
api(project(":aws-runtime:aws-core"))
// exposes Buffer/MutableBuffer and SdkByteReadChannel
api("aws.smithy.kotlin:io:$smithyKotlinVersion")
// exposes Flow<T>
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")

// exposes AwsSigningConfig
api(project(":aws-runtime:aws-signing"))
}
}

commonTest {
dependencies {
implementation(project(":aws-runtime:testing"))
api("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion")
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0.
*/

package aws.sdk.kotlin.runtime.protocol.eventstream

import aws.sdk.kotlin.runtime.auth.signing.*
import aws.sdk.kotlin.runtime.execution.AuthAttributes
import aws.smithy.kotlin.runtime.client.ExecutionContext
import aws.smithy.kotlin.runtime.io.SdkByteBuffer
import aws.smithy.kotlin.runtime.io.bytes
import aws.smithy.kotlin.runtime.time.Clock
import aws.smithy.kotlin.runtime.time.Instant
import aws.smithy.kotlin.runtime.util.InternalApi
import aws.smithy.kotlin.runtime.util.get
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow

/**
* Creates a flow that signs each event stream message with the given signing config.
*
* Each message's signature incorporates the signature of the previous message.
* The very first message incorporates the signature of the initial-request for
* both HTTP2 and WebSockets. The initial signature comes from the execution context.
*/
@InternalApi
public fun Flow<Message>.sign(
context: ExecutionContext,
config: AwsSigningConfig,
): Flow<Message> = flow {
val messages = this@sign

// NOTE: We need the signature of the initial HTTP request to seed the event stream signatures
// This is a bit of a chicken and egg problem since the event stream is constructed before the request
// is signed. The body of the stream shouldn't start being consumed though until after the entire request
// is built. Thus, by the time we get here the signature will exist in the context.
var prevSignature = context.getOrNull(AuthAttributes.RequestSignature) ?: error("expected initial HTTP signature to be set before message signing commences")

// signature date is updated per event message
val configBuilder = config.toBuilder()

messages.collect { message ->
// FIXME - can we get an estimate here on size?
val buffer = SdkByteBuffer(0U)
message.encode(buffer)

// the entire message is wrapped as the payload of the signed message
val result = signPayload(configBuilder, prevSignature, buffer.bytes())
prevSignature = result.signature
emit(result.output)
}
}
Comment on lines +28 to +53
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Am I reading correctly that this will collect every message (i.e., the entire event stream) and then emit a new stream with signed messages? Isn't that wasteful and unnecessarily greedy? Could/should we map instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is inside of a flow collector so collection is expected and how many of the provided transforms are implemented internally.

Note the = flow { on line 31. We are creating a new flow that processes elements from the current one. Collection will not happen until the returned flow is collected


internal suspend fun signPayload(
configBuilder: AwsSigningConfig.Builder,
prevSignature: ByteArray,
messagePayload: ByteArray,
clock: Clock = Clock.System
): SigningResult<Message> {
val dt = clock.now().truncateSubsecs()
val config = configBuilder.apply { date = dt }.build()

val result = sign(messagePayload, prevSignature, config)
val signature = result.signature

val signedMessage = buildMessage {
addHeader(":date", HeaderValue.Timestamp(dt))
addHeader(":chunk-signature", HeaderValue.ByteArray(signature))
payload = messagePayload
}

return SigningResult(signedMessage, signature)
}

/**
* Truncate the sub-seconds from the current time
*/
private fun Instant.truncateSubsecs(): Instant = Instant.fromEpochSeconds(epochSeconds, 0)

/**
* Create a new signing config for an event stream using the current context to set the operation/service specific
* configuration (e.g. region, signing service, credentials, etc)
*/
@InternalApi
public fun ExecutionContext.newEventStreamSigningConfig(): AwsSigningConfig = AwsSigningConfig {
algorithm = AwsSigningAlgorithm.SIGV4
signatureType = AwsSignatureType.HTTP_REQUEST_CHUNK
region = this@newEventStreamSigningConfig[AuthAttributes.SigningRegion]
service = this@newEventStreamSigningConfig[AuthAttributes.SigningService]
credentialsProvider = this@newEventStreamSigningConfig[AuthAttributes.CredentialsProvider]
useDoubleUriEncode = false
normalizeUriPath = true
Comment on lines +92 to +93
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: We purposely ignore these values in AwsSigningConfig? Do any services (e.g., S3) need special values here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We purposely ignore these values in AwsSigningConfig?

I don't follow...

Do any services (e.g., S3) need special values here?

Unknown. There is still some work left before I can send an input stream. I've validated most of the message serialization locally with some integration tests (that I will add to future PR after I clean them up). Signing is an area that may change in a future PR once I understand what we need to do better.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We purposely ignore these values in AwsSigningConfig?

I don't follow...

Sorry, disregard. I misread this code.

signedBodyHeader = AwsSignedBodyHeaderType.NONE

// FIXME - needs to be set on the operation for initial request
// signedBodyHeader = AwsSignedBodyHeaderType.X_AMZ_CONTENT_SHA256
// signedBodyValue = AwsSignedBodyValue.STREAMING_AWS4_HMAC_SHA256_PAYLOAD
}
Loading