diff --git a/gradle/jvm.gradle b/gradle/jvm.gradle index 239af75a5..e837a14a3 100644 --- a/gradle/jvm.gradle +++ b/gradle/jvm.gradle @@ -27,6 +27,9 @@ jvmTest { testLogging { events("passed", "skipped", "failed") showStandardStreams = true + showStackTraces = true + showExceptions = true + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL } useJUnitPlatform() diff --git a/runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/AwsSigningAttributes.kt b/runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/AwsSigningAttributes.kt index 50705eac8..dfab667a8 100644 --- a/runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/AwsSigningAttributes.kt +++ b/runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/AwsSigningAttributes.kt @@ -45,11 +45,11 @@ object AwsSigningAttributes { val CredentialsProvider: ClientOption = ClientOption("CredentialsProvider") /** - * The source for the body hash. + * The specification for determining the hash value for the request. * * **Note**: This is an advanced configuration option that does not normally need to be set manually. */ - val BodyHash: ClientOption = ClientOption("BodyHash") + val HashSpecification: ClientOption = ClientOption("HashSpecification") /** * The signed body header type. diff --git a/runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/AwsSigningConfig.kt b/runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/AwsSigningConfig.kt index e22380f28..44df8a05b 100644 --- a/runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/AwsSigningConfig.kt +++ b/runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/AwsSigningConfig.kt @@ -51,41 +51,6 @@ enum class AwsSignatureType { HTTP_REQUEST_EVENT, } -/** - * Identifies a source for calculating the body hash value - */ -sealed class BodyHash(open val hash: String?) { - /** - * The hash value should be calculated from the body payload - */ - object CalculateFromPayload : BodyHash(null) - - /** - * The hash value should indicate an unsigned payload - */ - object UnsignedPayload : BodyHash("UNSIGNED-PAYLOAD") - - /** - * The hash value should indicate an empty body - */ - object EmptyBody : BodyHash("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") // hash of "" - - /** - * The hash value should indicate that signature covers only headers and that there is no payload - */ - object StreamingAws4HmacSha256Payload : BodyHash("STREAMING-AWS4-HMAC-SHA256-PAYLOAD") - - /** - * The hash value should indicate ??? - */ - object StreamingAws4HmacSha256Events : BodyHash("STREAMING-AWS4-HMAC-SHA256-EVENTS") - - /** - * Use an explicit, precalculated value for the hash - */ - data class Precalculated(override val hash: String) : BodyHash(hash) -} - enum class AwsSignedBodyHeader { /** * Do not add a header. @@ -157,18 +122,21 @@ class AwsSigningConfig(builder: Builder) { val normalizeUriPath: Boolean = builder.normalizeUriPath /** - * Determines wheter the `X-Amz-Security-Token` query param should be omitted. Normally, this parameter is added - * during signing if the credentials have a session token. The only known case where this should be true is when - * signing a websocket handshake to IoT Core. + * Determines whether the `X-Amz-Security-Token` query param should be omitted from the canonical signing + * calculation. Normally, this parameter is added during signing if the credentials have a session token. The only + * known case where this should be true is when signing a websocket handshake to IoT Core. + * + * If this value is false, a non-null security token is _still added to the request_ but it is not used in signature + * calculation. */ val omitSessionToken: Boolean = builder.omitSessionToken /** * Determines the source of the canonical request's body public value. The default is - * [BodyHash.CalculateFromPayload], indicating that a public value will be calculated from the payload during - * signing. + * [HashSpecification.CalculateFromPayload], indicating that a public value will be calculated from the payload + * during signing. */ - val bodyHash: BodyHash = builder.bodyHash ?: BodyHash.CalculateFromPayload + val hashSpecification: HashSpecification = builder.hashSpecification ?: HashSpecification.CalculateFromPayload /** * Determines which body "hash" header, if any, should be added to the canonical request and the signed request. @@ -200,7 +168,7 @@ class AwsSigningConfig(builder: Builder) { it.useDoubleUriEncode = useDoubleUriEncode it.normalizeUriPath = normalizeUriPath it.omitSessionToken = omitSessionToken - it.bodyHash = bodyHash + it.hashSpecification = hashSpecification it.signedBodyHeader = signedBodyHeader it.credentialsProvider = credentialsProvider it.expiresAfter = expiresAfter @@ -216,7 +184,7 @@ class AwsSigningConfig(builder: Builder) { var useDoubleUriEncode: Boolean = true var normalizeUriPath: Boolean = true var omitSessionToken: Boolean = false - var bodyHash: BodyHash? = null + var hashSpecification: HashSpecification? = null var signedBodyHeader: AwsSignedBodyHeader = AwsSignedBodyHeader.NONE var credentialsProvider: CredentialsProvider? = null var expiresAfter: Duration? = null diff --git a/runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/HashSpecification.kt b/runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/HashSpecification.kt new file mode 100644 index 000000000..a7ffa1a9d --- /dev/null +++ b/runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/HashSpecification.kt @@ -0,0 +1,45 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ +package aws.smithy.kotlin.runtime.auth.awssigning + +/** + * Specifies a hash for a signable request + */ +sealed class HashSpecification { + /** + * Indicates that the hash value should be calculated from the body payload of the request + */ + object CalculateFromPayload : HashSpecification() + + /** + * Specifies a literal value to use as a hash + */ + sealed class HashLiteral(open val hash: String) : HashSpecification() + + /** + * The hash value should indicate an unsigned payload + */ + object UnsignedPayload : HashLiteral("UNSIGNED-PAYLOAD") + + /** + * The hash value should indicate an empty body + */ + object EmptyBody : HashLiteral("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") // hash of "" + + /** + * The hash value should indicate that signature covers only headers and that there is no payload + */ + object StreamingAws4HmacSha256Payload : HashLiteral("STREAMING-AWS4-HMAC-SHA256-PAYLOAD") + + /** + * The hash value should indicate ??? + */ + object StreamingAws4HmacSha256Events : HashLiteral("STREAMING-AWS4-HMAC-SHA256-EVENTS") + + /** + * Use an explicit, precalculated value for the hash + */ + data class Precalculated(override val hash: String) : HashLiteral(hash) +} diff --git a/runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/Presigner.kt b/runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/Presigner.kt index 065a52d04..40f395b3a 100644 --- a/runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/Presigner.kt +++ b/runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/Presigner.kt @@ -118,7 +118,8 @@ suspend fun createPresignedRequest( PresigningLocation.HEADER -> AwsSignatureType.HTTP_REQUEST_VIA_HEADERS PresigningLocation.QUERY_STRING -> AwsSignatureType.HTTP_REQUEST_VIA_QUERY_PARAMS } - val bodyHash = if (requestConfig.signBody) BodyHash.CalculateFromPayload else BodyHash.UnsignedPayload + val hashSpecification = + if (requestConfig.signBody) HashSpecification.CalculateFromPayload else HashSpecification.UnsignedPayload val signingConfig = AwsSigningConfig { region = endpoint.context?.region ?: serviceConfig.region @@ -126,7 +127,7 @@ suspend fun createPresignedRequest( credentialsProvider = serviceConfig.credentialsProvider this.signatureType = signatureType signedBodyHeader = AwsSignedBodyHeader.X_AMZ_CONTENT_SHA256 - this.bodyHash = bodyHash + this.hashSpecification = hashSpecification expiresAfter = requestConfig.expiresAfter useDoubleUriEncode = serviceConfig.useDoubleUriEncode normalizeUriPath = serviceConfig.normalizeUriPath diff --git a/runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/middleware/AwsSigningMiddleware.kt b/runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/middleware/AwsSigningMiddleware.kt index 3536781a3..e1b8bd5dc 100644 --- a/runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/middleware/AwsSigningMiddleware.kt +++ b/runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/middleware/AwsSigningMiddleware.kt @@ -105,7 +105,7 @@ class AwsSigningMiddleware(private val config: Config) : ModifyRequestMiddleware val body = req.subject.body // favor attributes from the current request context - val contextBodyHash = req.context.getOrNull(AwsSigningAttributes.BodyHash) + val contextHashSpecification = req.context.getOrNull(AwsSigningAttributes.HashSpecification) val contextSignedBodyHeader = req.context.getOrNull(AwsSigningAttributes.SignedBodyHeader) // operation signing config is baseConfig + operation specific config/overrides @@ -142,16 +142,16 @@ class AwsSigningMiddleware(private val config: Config) : ModifyRequestMiddleware // FIXME - see: https://github.com/awslabs/smithy-kotlin/issues/296 // if we know we have a (streaming) body and toSignableRequest() fails to convert it to a CRT equivalent // then we must decide how to compute the payload hash ourselves (defaults to unsigned payload) - bodyHash = when { - contextBodyHash != null -> contextBodyHash - config.isUnsignedPayload -> BodyHash.UnsignedPayload - body is HttpBody.Empty -> BodyHash.EmptyBody + hashSpecification = when { + contextHashSpecification != null -> contextHashSpecification + config.isUnsignedPayload -> HashSpecification.UnsignedPayload + body is HttpBody.Empty -> HashSpecification.EmptyBody body is HttpBody.Streaming && !body.isReplayable -> { logger.warn { "unable to compute hash for unbounded stream; defaulting to unsigned payload" } - BodyHash.UnsignedPayload + HashSpecification.UnsignedPayload } // use the payload to compute the hash - else -> BodyHash.CalculateFromPayload + else -> HashSpecification.CalculateFromPayload } } diff --git a/runtime/auth/aws-signing-crt/common/src/aws/smithy/kotlin/runtime/auth/awssigning/crt/CrtAwsSigner.kt b/runtime/auth/aws-signing-crt/common/src/aws/smithy/kotlin/runtime/auth/awssigning/crt/CrtAwsSigner.kt index ef56d3654..ee65ef3e9 100644 --- a/runtime/auth/aws-signing-crt/common/src/aws/smithy/kotlin/runtime/auth/awssigning/crt/CrtAwsSigner.kt +++ b/runtime/auth/aws-signing-crt/common/src/aws/smithy/kotlin/runtime/auth/awssigning/crt/CrtAwsSigner.kt @@ -79,7 +79,7 @@ private suspend fun AwsSigningConfig.toCrtSigningConfig(): CrtSigningConfig { useDoubleUriEncode = src.useDoubleUriEncode normalizeUriPath = src.normalizeUriPath omitSessionToken = src.omitSessionToken - signedBodyValue = src.bodyHash.hash + signedBodyValue = src.hashSpecification.toCrtSignedBodyValue() signedBodyHeader = src.signedBodyHeader.toCrtSignedBodyHeaderType() credentials = srcCredentials.toCrtCredentials() expirationInSeconds = src.expiresAfter?.inWholeSeconds ?: 0 @@ -88,6 +88,11 @@ private suspend fun AwsSigningConfig.toCrtSigningConfig(): CrtSigningConfig { private fun Credentials.toCrtCredentials() = CrtCredentials(accessKeyId, secretAccessKey, sessionToken) +private fun HashSpecification.toCrtSignedBodyValue(): String? = when (this) { + is HashSpecification.CalculateFromPayload -> null + is HashSpecification.HashLiteral -> hash +} + private fun Headers.toCrtHeaders(): CrtHeaders { val headersBuilder = aws.sdk.kotlin.crt.http.HeadersBuilder() forEach(headersBuilder::appendAll) diff --git a/runtime/auth/aws-signing-crt/jvm/test/aws/smithy/kotlin/runtime/auth/awssigning/crt/CrtSigningSuiteTest.kt b/runtime/auth/aws-signing-crt/jvm/test/aws/smithy/kotlin/runtime/auth/awssigning/crt/CrtSigningSuiteTest.kt index 33fcd256e..e7254fd54 100644 --- a/runtime/auth/aws-signing-crt/jvm/test/aws/smithy/kotlin/runtime/auth/awssigning/crt/CrtSigningSuiteTest.kt +++ b/runtime/auth/aws-signing-crt/jvm/test/aws/smithy/kotlin/runtime/auth/awssigning/crt/CrtSigningSuiteTest.kt @@ -10,17 +10,7 @@ import aws.smithy.kotlin.runtime.auth.awssigning.tests.SigningSuiteTestBase class CrtSigningSuiteTest : SigningSuiteTestBase() { override val signer: AwsSigner = CrtAwsSigner - override val disabledTests = setOf( - // ktor-http-cio parser doesn't support parsing multiline headers since they are deprecated in RFC7230 - "get-header-value-multiline", - // ktor fails to parse with space in it (expects it to be a valid request already encoded) - "get-space-normalized", - "get-space-unnormalized", - - // no signed request to test against - "get-vanilla-query-order-key", - "get-vanilla-query-order-value", - + override val disabledTests = super.disabledTests + setOf( // FIXME - Signature mismatch possibly related to https://github.com/awslabs/aws-crt-java/pull/419. Needs // investigation. "get-utf8", diff --git a/runtime/auth/aws-signing-default/build.gradle.kts b/runtime/auth/aws-signing-default/build.gradle.kts new file mode 100644 index 000000000..e4b007b4e --- /dev/null +++ b/runtime/auth/aws-signing-default/build.gradle.kts @@ -0,0 +1,32 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ +description = "AWS Signer default implementation" +extra["displayName"] = "Smithy :: Kotlin :: Standard AWS Signer" +extra["moduleName"] = "aws.smithy.kotlin.runtime.auth.awssigning" + +val coroutinesVersion: String by project + +kotlin { + sourceSets { + commonMain { + dependencies { + api(project(":runtime:auth:aws-signing-common")) + implementation(project(":runtime:hashing")) + implementation(project(":runtime:logging")) + } + } + + commonTest { + dependencies { + implementation(project(":runtime:auth:aws-signing-tests")) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion") + } + } + + all { + languageSettings.optIn("aws.smithy.kotlin.runtime.util.InternalApi") + } + } +} diff --git a/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/Canonicalizer.kt b/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/Canonicalizer.kt new file mode 100644 index 000000000..f6ddb5c84 --- /dev/null +++ b/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/Canonicalizer.kt @@ -0,0 +1,207 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ +package aws.smithy.kotlin.runtime.auth.awssigning + +import aws.smithy.kotlin.runtime.auth.awscredentials.Credentials +import aws.smithy.kotlin.runtime.hashing.HashSupplier +import aws.smithy.kotlin.runtime.hashing.Sha256 +import aws.smithy.kotlin.runtime.hashing.hash +import aws.smithy.kotlin.runtime.http.HttpBody +import aws.smithy.kotlin.runtime.http.UrlBuilder +import aws.smithy.kotlin.runtime.http.request.HttpRequest +import aws.smithy.kotlin.runtime.http.request.HttpRequestBuilder +import aws.smithy.kotlin.runtime.http.request.toBuilder +import aws.smithy.kotlin.runtime.io.SdkByteReadChannel +import aws.smithy.kotlin.runtime.time.TimestampFormat +import aws.smithy.kotlin.runtime.util.* +import aws.smithy.kotlin.runtime.util.text.encodeUrlPath +import aws.smithy.kotlin.runtime.util.text.normalizePathSegments +import aws.smithy.kotlin.runtime.util.text.urlEncodeComponent +import aws.smithy.kotlin.runtime.util.text.urlReencodeComponent + +/** + * The data for a canonical request. + * @param request The [HttpRequestBuilder] with modified/added headers or query parameters + * @param requestString The canonical request string which is used in signature calculation + * @param signedHeaders A semicolon-delimited list of signed headers, all lowercase + * @param hash The hash for the body (either calculated from the body or pre-configured) + */ +internal data class CanonicalRequest( + val request: HttpRequestBuilder, + val requestString: String, + val signedHeaders: String, + val hash: String, +) + +/** + * An object that can canonicalize a request. + */ +internal interface Canonicalizer { + companion object { + /** + * The default implementation of [Canonicalizer] to use. + */ + val Default = DefaultCanonicalizer() + } + + /** + * Canonicalize a request + * @param request The [HttpRequest] containing the data ready for signing + * @param config The signing parameters to use + * @param credentials Retrieved credentials used to canonicalize the request + */ + suspend fun canonicalRequest( + request: HttpRequest, + config: AwsSigningConfig, + credentials: Credentials, + ): CanonicalRequest +} + +// Taken from https://github.com/awslabs/aws-c-auth/blob/31d573c0dd328db5775f7a55650d27b8c08311ba/source/aws_signing.c#L118-L151 +private val skipHeaders = setOf( + "connection", + "sec-websocket-key", + "sec-websocket-protocol", + "sec-websocket-version", + "upgrade", + "user-agent", + "x-amzn-trace-id", +) + +internal class DefaultCanonicalizer(private val sha256Supplier: HashSupplier = ::Sha256) : Canonicalizer { + override suspend fun canonicalRequest( + request: HttpRequest, + config: AwsSigningConfig, + credentials: Credentials, + ): CanonicalRequest { + val hash = when (val hashSpec = config.hashSpecification) { + is HashSpecification.CalculateFromPayload -> request.body.calculateHash() + is HashSpecification.HashLiteral -> hashSpec.hash + } + + val signViaQueryParams = config.signatureType == AwsSignatureType.HTTP_REQUEST_VIA_QUERY_PARAMS + val addHashHeader = !signViaQueryParams && config.signedBodyHeader == AwsSignedBodyHeader.X_AMZ_CONTENT_SHA256 + val sessionToken = credentials.sessionToken?.let { if (signViaQueryParams) it.urlEncodeComponent() else it } + + val builder = request.toBuilder() + + val params = when (config.signatureType) { + AwsSignatureType.HTTP_REQUEST_VIA_HEADERS -> builder.headers + AwsSignatureType.HTTP_REQUEST_VIA_QUERY_PARAMS -> builder.url.parameters + else -> TODO("Support for ${config.signatureType} is not yet implemented") + } + fun param(name: String, value: String?, predicate: Boolean = true) { + if (predicate && value != null) params[name] = value + } + + param("Host", builder.url.host, !(signViaQueryParams || "Host" in params)) + param("X-Amz-Algorithm", ALGORITHM_NAME, signViaQueryParams) + param("X-Amz-Credential", credentialValue(config, credentials), signViaQueryParams) + param("X-Amz-Content-Sha256", hash, addHashHeader) + param("X-Amz-Date", config.signingDate.format(TimestampFormat.ISO_8601_CONDENSED)) + param("X-Amz-Expires", config.expiresAfter?.inWholeSeconds?.toString(), signViaQueryParams) + param("X-Amz-Security-Token", sessionToken, !config.omitSessionToken) // Add pre-sig if omitSessionToken=false + + val headers = builder + .headers + .entries() + .asSequence() + .filter { includeHeader(it.key, config) } + .map { it.key.lowercase() to it.value } + .sortedBy { it.first } + val signedHeaders = headers.joinToString(separator = ";") { it.first } + + param("X-Amz-SignedHeaders", signedHeaders, signViaQueryParams) + + val requestString = buildString { + appendLine(builder.method.name) + appendLine(builder.url.canonicalPath(config)) + appendLine(builder.url.canonicalQueryParams()) + headers.map { it.canonicalLine() }.forEach(::appendLine) + appendLine() // Yes, add an extra blank line after all the headers + appendLine(signedHeaders) + append(hash) + } + + param("X-Amz-Security-Token", sessionToken, config.omitSessionToken) // Add post-sig if omitSessionToken=true + + return CanonicalRequest(builder, requestString, signedHeaders, hash) + } + + /** + * Calculate a hash for this [HttpBody]. This method does not support attempting to calculate a hash on a + * non-replayable stream. + * @return The hash as a hex string + */ + private suspend fun HttpBody.calculateHash(): String = when (this) { + is HttpBody.Empty -> HashSpecification.EmptyBody.hash + is HttpBody.Bytes -> bytes().hash(sha256Supplier).encodeToHex() + is HttpBody.Streaming -> { + require(isReplayable) { "Stream must be replayable to calculate a body hash" } + val reader = readFrom() + reader.sha256().encodeToHex().also { reset() } + } + } + + /** + * Calculate a hash for this [SdkByteReadChannel]. + * @return The hash as a byte array + */ + private suspend fun SdkByteReadChannel.sha256(): ByteArray { + val hash = sha256Supplier() + + val sink = ByteArray(STREAM_CHUNK_BYTES) + while (!isClosedForRead || availableForRead > 0) { + val bytesRead = readAvailable(sink) + if (bytesRead <= 0) break + hash.update(sink, offset = 0, length = bytesRead) + } + + return hash.digest() + } +} + +/** The number of bytes to read at a time during SHA256 calculation on streaming bodies. */ +private const val STREAM_CHUNK_BYTES = 16384 // 16KB + +/** + * Canonicalizes a path from this [UrlBuilder]. + * @param config The signing configuration to use + * @return The canonicalized path + */ +private fun UrlBuilder.canonicalPath(config: AwsSigningConfig): String { + val raw = path.trim() + val normalized = if (config.normalizeUriPath) raw.normalizePathSegments() else raw + val preEncoded = normalized.encodeUrlPath() + return if (config.useDoubleUriEncode) preEncoded.encodeUrlPath() else preEncoded +} + +/** + * Canonicalizes the query parameters from this [UrlBuilder]. + * @return The canonicalized query parameters + */ +private fun UrlBuilder.canonicalQueryParams(): String = parameters + .entries() + .map { it.key.urlReencodeComponent() to it.value } + .sortedBy { it.first } + .flatMap { it.asQueryParamComponents() } + .joinToString(separator = "&") + +private fun Pair>.asQueryParamComponents(): List = + second + .map { this@asQueryParamComponents.first to it.urlReencodeComponent() } + .sortedBy { it.second } + .map { "${it.first}=${it.second}" } + +private fun Pair>.canonicalLine(): String { + val valuesString = second.joinToString(separator = ",") { it.trimAll() } + return "$first:$valuesString" +} + +private val multipleSpaces = " +".toRegex() +private fun String.trimAll() = replace(multipleSpaces, " ").trim() + +private fun includeHeader(name: String, config: AwsSigningConfig): Boolean = + name.lowercase() !in skipHeaders && config.shouldSignHeader(name) diff --git a/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/DefaultAwsSigner.kt b/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/DefaultAwsSigner.kt new file mode 100644 index 000000000..cacdd2975 --- /dev/null +++ b/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/DefaultAwsSigner.kt @@ -0,0 +1,78 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ +package aws.smithy.kotlin.runtime.auth.awssigning + +import aws.smithy.kotlin.runtime.auth.awscredentials.Credentials +import aws.smithy.kotlin.runtime.http.request.HttpRequest +import aws.smithy.kotlin.runtime.logging.Logger +import aws.smithy.kotlin.runtime.time.TimestampFormat + +/** The default implementation of [AwsSigner] */ +val DefaultAwsSigner: AwsSigner = DefaultAwsSignerImpl() + +internal class DefaultAwsSignerImpl( + private val canonicalizer: Canonicalizer = Canonicalizer.Default, + private val signatureCalculator: SignatureCalculator = SignatureCalculator.Default, + private val requestMutator: RequestMutator = RequestMutator.Default, +) : AwsSigner { + private val logger = Logger.getLogger() + + override suspend fun sign(request: HttpRequest, config: AwsSigningConfig): AwsSigningResult { + // TODO implement SigV4a + require(config.algorithm == AwsSigningAlgorithm.SIGV4) { "${config.algorithm} support is not yet implemented" } + + val credentials = config.credentialsProvider.getCredentials() + + val canonical = canonicalizer.canonicalRequest(request, config, credentials) + logger.trace { "Canonical request:\n${canonical.requestString}" } + + val stringToSign = signatureCalculator.stringToSign(canonical.requestString, config) + logger.trace { "String to sign:\n$stringToSign" } + + val signingKey = signatureCalculator.signingKey(config, credentials) + + val signature = signatureCalculator.calculate(signingKey, stringToSign) + logger.debug { "Calculated signature: $signature" } + + val signedRequest = requestMutator.appendAuth(config, canonical, credentials, signature) + + return AwsSigningResult(signedRequest, signature.encodeToByteArray()) + } + + override suspend fun signChunk( + chunkBody: ByteArray, + prevSignature: ByteArray, + config: AwsSigningConfig, + ): AwsSigningResult { + val stringToSign = signatureCalculator.chunkStringToSign(chunkBody, prevSignature, config) + logger.trace { "Chunk string to sign:\n$stringToSign" } + + val credentials = config.credentialsProvider.getCredentials() + val signingKey = signatureCalculator.signingKey(config, credentials) + + val signature = signatureCalculator.calculate(signingKey, stringToSign) + logger.debug { "Calculated chunk signature: $signature" } + + return AwsSigningResult(Unit, signature.encodeToByteArray()) + } +} + +/** The name of the SigV4 algorithm. */ +internal const val ALGORITHM_NAME = "AWS4-HMAC-SHA256" + +/** + * Formats a credential scope consisting of a signing date, region, service, and a signature type + */ +internal val AwsSigningConfig.credentialScope: String + get() { + val signingDate = signingDate.format(TimestampFormat.ISO_8601_CONDENSED_DATE) + return "$signingDate/$region/$service/aws4_request" + } + +/** + * Formats the value for a credential header/parameter + */ +internal fun credentialValue(config: AwsSigningConfig, credentials: Credentials): String = + "${credentials.accessKeyId}/${config.credentialScope}" diff --git a/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/RequestMutator.kt b/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/RequestMutator.kt new file mode 100644 index 000000000..08f373dd8 --- /dev/null +++ b/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/RequestMutator.kt @@ -0,0 +1,70 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ +package aws.smithy.kotlin.runtime.auth.awssigning + +import aws.smithy.kotlin.runtime.auth.awscredentials.Credentials +import aws.smithy.kotlin.runtime.http.request.HttpRequest +import aws.smithy.kotlin.runtime.util.text.urlReencodeComponent + +/** + * An object that can mutate requests to include signing attributes. + */ +internal interface RequestMutator { + companion object { + /** + * The default implementation of [RequestMutator]. + */ + val Default = DefaultRequestMutator() + } + + /** + * Appends authorization information to a canonical request, returning a new request ready to be sent. + * @param config The signing configuration to use + * @param canonical The [CanonicalRequest] which has already been modified + * @param credentials The retrieved credentials used in the signing process + * @param signatureHex The signature as a hex string + * @return A new [HttpRequest] containing all the relevant signing/authorization attributes which is ready to be + * sent. + */ + fun appendAuth( + config: AwsSigningConfig, + canonical: CanonicalRequest, + credentials: Credentials, + signatureHex: String, + ): HttpRequest +} + +internal class DefaultRequestMutator : RequestMutator { + override fun appendAuth( + config: AwsSigningConfig, + canonical: CanonicalRequest, + credentials: Credentials, + signatureHex: String, + ): HttpRequest { + when (config.signatureType) { + AwsSignatureType.HTTP_REQUEST_VIA_HEADERS -> { + val credential = "Credential=${credentialValue(config, credentials)}" + val signedHeaders = "SignedHeaders=${canonical.signedHeaders}" + val signature = "Signature=$signatureHex" + canonical.request.headers["Authorization"] = "$ALGORITHM_NAME $credential, $signedHeaders, $signature" + } + + AwsSignatureType.HTTP_REQUEST_VIA_QUERY_PARAMS -> { + with(canonical.request.url.parameters) { + set("X-Amz-Signature", signatureHex) + + entries().forEach { + remove(it.key) + appendAll(it.key, it.value.map(String::urlReencodeComponent)) + } + } + } + + else -> TODO("Support for ${config.signatureType} is not yet implemented") + } + + return canonical.request.build() + } +} diff --git a/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/SignatureCalculator.kt b/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/SignatureCalculator.kt new file mode 100644 index 000000000..68f997483 --- /dev/null +++ b/runtime/auth/aws-signing-default/common/src/aws/smithy/kotlin/runtime/auth/awssigning/SignatureCalculator.kt @@ -0,0 +1,88 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ +package aws.smithy.kotlin.runtime.auth.awssigning + +import aws.smithy.kotlin.runtime.auth.awscredentials.Credentials +import aws.smithy.kotlin.runtime.hashing.* +import aws.smithy.kotlin.runtime.time.TimestampFormat +import aws.smithy.kotlin.runtime.util.encodeToHex + +/** + * An object that can calculate signatures based on canonical requests. + */ +internal interface SignatureCalculator { + companion object { + /** + * The default implementation of [SignatureCalculator]. + */ + val Default = DefaultSignatureCalculator() + } + + /** + * Calculates a signature based on a signing key and a string to sign. + * @param signingKey The signing key as a byte array (returned from [signingKey]) + * @param stringToSign The string to sign (returned from [stringToSign] or [chunkStringToSign]) + * @return The signature for this request as a hex string + */ + fun calculate(signingKey: ByteArray, stringToSign: String): String + + /** + * Constructs a string to sign for a chunk + * @param chunkBody The byte contents of the chunk's body + * @param prevSignature The signature of the previous chunk. If this is the first chunk, use the seed signature. + * @param config The signing configuration to use + * @return A multiline string to sign + */ + fun chunkStringToSign(chunkBody: ByteArray, prevSignature: ByteArray, config: AwsSigningConfig): String + + /** + * Derives a signing key + * @param config The signing configuration to use + * @param credentials Retrieved credentials to use + * @return The signing key as a byte array + */ + fun signingKey(config: AwsSigningConfig, credentials: Credentials): ByteArray + + /** + * Constructs a string to sign for a request + * @param canonicalRequest The canonical request string (returned from [Canonicalizer.canonicalRequest]) + * @param config The signing configuration to use + * @return A multiline string to sign + */ + fun stringToSign(canonicalRequest: String, config: AwsSigningConfig): String +} + +internal class DefaultSignatureCalculator(private val sha256Provider: HashSupplier = ::Sha256) : SignatureCalculator { + override fun calculate(signingKey: ByteArray, stringToSign: String): String = + hmac(signingKey, stringToSign.encodeToByteArray(), sha256Provider).encodeToHex() + + override fun chunkStringToSign(chunkBody: ByteArray, prevSignature: ByteArray, config: AwsSigningConfig): String = + buildString { + appendLine("AWS4-HMAC-SHA256-PAYLOAD") + appendLine(config.signingDate.format(TimestampFormat.ISO_8601_CONDENSED)) + appendLine(config.credentialScope) + appendLine(prevSignature.decodeToString()) // Should already be a byte array of ASCII hex chars + appendLine(HashSpecification.EmptyBody.hash) + append(chunkBody.hash(sha256Provider).encodeToHex()) + } + + override fun signingKey(config: AwsSigningConfig, credentials: Credentials): ByteArray { + fun hmac(key: ByteArray, message: String) = hmac(key, message.encodeToByteArray(), sha256Provider) + + val initialKey = ("AWS4" + credentials.secretAccessKey).encodeToByteArray() + val kDate = hmac(initialKey, config.signingDate.format(TimestampFormat.ISO_8601_CONDENSED_DATE)) + val kRegion = hmac(kDate, config.region) + val kService = hmac(kRegion, config.service) + return hmac(kService, "aws4_request") + } + + override fun stringToSign(canonicalRequest: String, config: AwsSigningConfig): String = + buildString { + appendLine("AWS4-HMAC-SHA256") + appendLine(config.signingDate.format(TimestampFormat.ISO_8601_CONDENSED)) + appendLine(config.credentialScope) + append(canonicalRequest.encodeToByteArray().hash(sha256Provider).encodeToHex()) + } +} diff --git a/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultBasicSigningTest.kt b/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultBasicSigningTest.kt new file mode 100644 index 000000000..c267d2963 --- /dev/null +++ b/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultBasicSigningTest.kt @@ -0,0 +1,18 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ +package aws.smithy.kotlin.runtime.auth.awssigning + +import aws.smithy.kotlin.runtime.auth.awssigning.tests.BasicSigningTestBase +import kotlinx.coroutines.test.TestResult +import kotlin.test.Ignore +import kotlin.test.Test + +class DefaultBasicSigningTest : BasicSigningTestBase() { + override val signer: AwsSigner = DefaultAwsSigner + + @Ignore + @Test + override fun testSignRequestSigV4Asymmetric(): TestResult = TODO("Add support for SigV4a in default signer") +} diff --git a/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultCanonicalizerTest.kt b/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultCanonicalizerTest.kt new file mode 100644 index 000000000..a3d36cc7e --- /dev/null +++ b/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultCanonicalizerTest.kt @@ -0,0 +1,83 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ +package aws.smithy.kotlin.runtime.auth.awssigning + +import aws.smithy.kotlin.runtime.auth.awscredentials.Credentials +import aws.smithy.kotlin.runtime.auth.awssigning.tests.testCredentialsProvider +import aws.smithy.kotlin.runtime.http.Headers +import aws.smithy.kotlin.runtime.http.HttpBody +import aws.smithy.kotlin.runtime.http.HttpMethod +import aws.smithy.kotlin.runtime.http.parameters +import aws.smithy.kotlin.runtime.http.request.* +import aws.smithy.kotlin.runtime.time.Instant +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultCanonicalizerTest { + // Test adapted from https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html + @Test + fun testCanonicalize() = runTest { + val request = HttpRequest { + method = HttpMethod.GET + url { + host = "iam.amazonaws.com" + path = "" + parameters { + set("Action", "ListUsers") + set("Version", "2010-05-08") + } + } + headers { + set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") + } + body = HttpBody.Empty + } + + val signingDateString = "20150830T123600Z" + val config = AwsSigningConfig { + region = "foo" + service = "bar" + signingDate = Instant.fromIso8601(signingDateString) + credentialsProvider = testCredentialsProvider + } + val credentials = Credentials("foo", "bar") // anything without a session token set + + val canonicalizer = Canonicalizer.Default + val actual = canonicalizer.canonicalRequest(request, config, credentials) + + val expectedHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + assertEquals(expectedHash, actual.hash) + + val expectedSignedHeaders = "content-type;host;x-amz-date" + assertEquals(expectedSignedHeaders, actual.signedHeaders) + + val expectedRequestString = """ + GET + / + Action=ListUsers&Version=2010-05-08 + content-type:application/x-www-form-urlencoded; charset=utf-8 + host:iam.amazonaws.com + x-amz-date:20150830T123600Z + + $expectedSignedHeaders + $expectedHash + """.trimIndent() + assertEquals(expectedRequestString, actual.requestString) + + assertEquals(request.method, actual.request.method) + assertEquals(request.url.toString(), actual.request.url.build().toString()) + assertEquals(request.body, actual.request.body) + + val expectedHeaders = Headers { + appendAll(request.headers) + append("Host", request.url.host) + append("X-Amz-Date", signingDateString) + }.entries() + assertEquals(expectedHeaders, actual.request.headers.entries()) + } +} diff --git a/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultMiddlewareSigningTest.kt b/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultMiddlewareSigningTest.kt new file mode 100644 index 000000000..907806e1b --- /dev/null +++ b/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultMiddlewareSigningTest.kt @@ -0,0 +1,11 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ +package aws.smithy.kotlin.runtime.auth.awssigning + +import aws.smithy.kotlin.runtime.auth.awssigning.tests.MiddlewareSigningTestBase + +class DefaultMiddlewareSigningTest : MiddlewareSigningTestBase() { + override val signer: AwsSigner = DefaultAwsSigner +} diff --git a/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultRequestMutatorTest.kt b/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultRequestMutatorTest.kt new file mode 100644 index 000000000..d9460f232 --- /dev/null +++ b/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultRequestMutatorTest.kt @@ -0,0 +1,69 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ +package aws.smithy.kotlin.runtime.auth.awssigning + +import aws.smithy.kotlin.runtime.auth.awscredentials.Credentials +import aws.smithy.kotlin.runtime.auth.awssigning.tests.testCredentialsProvider +import aws.smithy.kotlin.runtime.http.Headers +import aws.smithy.kotlin.runtime.http.HttpBody +import aws.smithy.kotlin.runtime.http.HttpMethod +import aws.smithy.kotlin.runtime.http.parameters +import aws.smithy.kotlin.runtime.http.request.* +import aws.smithy.kotlin.runtime.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals + +class DefaultRequestMutatorTest { + @Test + fun testAppendAuthHeader() { + val canonical = CanonicalRequest(baseRequest.toBuilder(), "", "action;host;x-amz-date", "") + val credentials = Credentials("", "secret key") + val signature = "0123456789abcdef" + + val config = AwsSigningConfig { + region = "us-west-2" + service = "fooservice" + signingDate = Instant.fromIso8601("20220427T012345Z") + credentialsProvider = testCredentialsProvider + omitSessionToken = true + } + + val mutated = RequestMutator.Default.appendAuth(config, canonical, credentials, signature) + + assertEquals(baseRequest.method, mutated.method) + assertEquals(baseRequest.url.toString(), mutated.url.toString()) + assertEquals(baseRequest.body, mutated.body) + + val expectedCredentialScope = "20220427/us-west-2/fooservice/aws4_request" + val expectedAuthValue = + "AWS4-HMAC-SHA256 Credential=${credentials.accessKeyId}/$expectedCredentialScope, " + + "SignedHeaders=${canonical.signedHeaders}, Signature=$signature" + val expectedHeaders = Headers { + appendAll(baseRequest.headers) + append("Authorization", expectedAuthValue) + }.entries() + + assertEquals(expectedHeaders, mutated.headers.entries()) + } +} + +private val baseRequest = HttpRequest { + method = HttpMethod.GET + url { + host = "foo.com" + path = "bar/baz" + parameters { + append("a", "apple") + append("b", "banana") + append("c", "cherry") + } + } + headers { + append("d", "durian") + append("e", "elderberry") + append("f", "fig") + } + body = HttpBody.fromBytes("hello world!".encodeToByteArray()) +} diff --git a/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultSignatureCalculatorTest.kt b/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultSignatureCalculatorTest.kt new file mode 100644 index 000000000..327ea2969 --- /dev/null +++ b/runtime/auth/aws-signing-default/common/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultSignatureCalculatorTest.kt @@ -0,0 +1,83 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ +package aws.smithy.kotlin.runtime.auth.awssigning + +import aws.smithy.kotlin.runtime.auth.awscredentials.Credentials +import aws.smithy.kotlin.runtime.auth.awssigning.tests.testCredentialsProvider +import aws.smithy.kotlin.runtime.time.Instant +import aws.smithy.kotlin.runtime.util.decodeHexBytes +import aws.smithy.kotlin.runtime.util.encodeToHex +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class DefaultSignatureCalculatorTest { + // Test adapted from https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html + @Test + fun testCalculate() { + val signingKey = "c4afb1cc5771d871763a393e44b703571b55cc28424d1a5e86da6ed3c154a4b9".decodeHexBytes() + val stringToSign = """ + AWS4-HMAC-SHA256 + 20150830T123600Z + 20150830/us-east-1/iam/aws4_request + f536975d06c0309214f805bb90ccff089219ecd68b2577efef23edd43b7e1a59 + """.trimIndent() + + val expected = "5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7" + val actual = SignatureCalculator.Default.calculate(signingKey, stringToSign) + assertEquals(expected, actual) + } + + // Test adapted from https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun testSigningKey() = runTest { + val credentials = Credentials("", "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY") + + val config = AwsSigningConfig { + signingDate = Instant.fromIso8601("20150830") + region = "us-east-1" + service = "iam" + credentialsProvider = testCredentialsProvider + } + + val expected = "c4afb1cc5771d871763a393e44b703571b55cc28424d1a5e86da6ed3c154a4b9" + val actual = SignatureCalculator.Default.signingKey(config, credentials).encodeToHex() + assertEquals(expected, actual) + } + + // Test adapted from https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html + @Test + fun testStringToSign() { + val canonicalRequest = """ + GET + / + Action=ListUsers&Version=2010-05-08 + content-type:application/x-www-form-urlencoded; charset=utf-8 + host:iam.amazonaws.com + x-amz-date:20150830T123600Z + + content-type;host;x-amz-date + e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + """.trimIndent() + + val config = AwsSigningConfig { + signingDate = Instant.fromIso8601("20150830T123600Z") + region = "us-east-1" + service = "iam" + credentialsProvider = testCredentialsProvider + } + + val expected = """ + AWS4-HMAC-SHA256 + 20150830T123600Z + 20150830/us-east-1/iam/aws4_request + f536975d06c0309214f805bb90ccff089219ecd68b2577efef23edd43b7e1a59 + """.trimIndent() + val actual = SignatureCalculator.Default.stringToSign(canonicalRequest, config) + assertEquals(expected, actual) + } +} diff --git a/runtime/auth/aws-signing-default/jvm/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultSigningSuiteTest.kt b/runtime/auth/aws-signing-default/jvm/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultSigningSuiteTest.kt new file mode 100644 index 000000000..798a76ad0 --- /dev/null +++ b/runtime/auth/aws-signing-default/jvm/test/aws/smithy/kotlin/runtime/auth/awssigning/DefaultSigningSuiteTest.kt @@ -0,0 +1,32 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ +package aws.smithy.kotlin.runtime.auth.awssigning + +import aws.smithy.kotlin.runtime.auth.awssigning.tests.SigningStateProvider +import aws.smithy.kotlin.runtime.auth.awssigning.tests.SigningSuiteTestBase + +class DefaultSigningSuiteTest : SigningSuiteTestBase() { + override val signer: AwsSigner = DefaultAwsSigner + + override val canonicalRequestProvider: SigningStateProvider = { request, config -> + val credentials = config.credentialsProvider.getCredentials() + val result = Canonicalizer.Default.canonicalRequest(request, config, credentials) + result.requestString + } + + override val signatureProvider: SigningStateProvider = { request, config -> + val credentials = config.credentialsProvider.getCredentials() + val canonical = Canonicalizer.Default.canonicalRequest(request, config, credentials) + val stringToSign = SignatureCalculator.Default.stringToSign(canonical.requestString, config) + val signingKey = SignatureCalculator.Default.signingKey(config, credentials) + SignatureCalculator.Default.calculate(signingKey, stringToSign) + } + + override val stringToSignProvider: SigningStateProvider = { request, config -> + val credentials = config.credentialsProvider.getCredentials() + val canonical = Canonicalizer.Default.canonicalRequest(request, config, credentials) + SignatureCalculator.Default.stringToSign(canonical.requestString, config) + } +} diff --git a/runtime/auth/aws-signing-tests/common/src/aws/smithy/kotlin/runtime/auth/awssigning/tests/BasicSigningTestBase.kt b/runtime/auth/aws-signing-tests/common/src/aws/smithy/kotlin/runtime/auth/awssigning/tests/BasicSigningTestBase.kt index 0d4de13b9..dbe5de55e 100644 --- a/runtime/auth/aws-signing-tests/common/src/aws/smithy/kotlin/runtime/auth/awssigning/tests/BasicSigningTestBase.kt +++ b/runtime/auth/aws-signing-tests/common/src/aws/smithy/kotlin/runtime/auth/awssigning/tests/BasicSigningTestBase.kt @@ -80,7 +80,7 @@ abstract class BasicSigningTestBase : HasSigner { } @Test - fun testSignRequestSigV4Asymmetric() = runTest { + open fun testSignRequestSigV4Asymmetric() = runTest { // sanity test val request = HttpRequestBuilder().apply { method = HttpMethod.POST @@ -118,7 +118,7 @@ abstract class BasicSigningTestBase : HasSigner { useDoubleUriEncode = false normalizeUriPath = true signedBodyHeader = AwsSignedBodyHeader.X_AMZ_CONTENT_SHA256 - bodyHash = BodyHash.StreamingAws4HmacSha256Payload + hashSpecification = HashSpecification.StreamingAws4HmacSha256Payload credentialsProvider = CHUNKED_TEST_CREDENTIALS_PROVIDER } diff --git a/runtime/auth/aws-signing-tests/jvm/src/aws/smithy/kotlin/runtime/auth/awssigning/tests/SigningSuiteTestBaseJVM.kt b/runtime/auth/aws-signing-tests/jvm/src/aws/smithy/kotlin/runtime/auth/awssigning/tests/SigningSuiteTestBaseJVM.kt index 9b9fa4467..ef79d2cbf 100644 --- a/runtime/auth/aws-signing-tests/jvm/src/aws/smithy/kotlin/runtime/auth/awssigning/tests/SigningSuiteTestBaseJVM.kt +++ b/runtime/auth/aws-signing-tests/jvm/src/aws/smithy/kotlin/runtime/auth/awssigning/tests/SigningSuiteTestBaseJVM.kt @@ -17,6 +17,7 @@ import aws.smithy.kotlin.runtime.http.request.HttpRequest import aws.smithy.kotlin.runtime.http.request.HttpRequestBuilder import aws.smithy.kotlin.runtime.http.response.HttpCall import aws.smithy.kotlin.runtime.http.response.HttpResponse +import aws.smithy.kotlin.runtime.http.util.StringValuesMap import aws.smithy.kotlin.runtime.http.util.fullUriToQueryParameters import aws.smithy.kotlin.runtime.time.Instant import aws.smithy.kotlin.runtime.util.InternalApi @@ -27,6 +28,7 @@ import io.ktor.utils.io.* import io.ktor.utils.io.core.* import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.* +import org.junit.jupiter.api.Assumptions.assumeTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest @@ -63,8 +65,11 @@ private val defaultTestSigningConfig = AwsSigningConfig.Builder().apply { data class Sigv4TestSuiteTest( val path: Path, val request: HttpRequestBuilder, + val canonicalRequest: String, + val stringToSign: String, + val signature: String, val signedRequest: HttpRequestBuilder, - val config: AwsSigningConfig = defaultTestSigningConfig.build() + val config: AwsSigningConfig = defaultTestSigningConfig.build(), ) { override fun toString() = path.fileName.toString() } @@ -76,6 +81,15 @@ private val testSuitePath: Path by lazy { FileSystems.newFileSystem(uri, mapOf()).getPath("/aws-signing-test-suite/v4") } +private val AwsSignatureType.fileNamePart: String + get() = when (this) { + AwsSignatureType.HTTP_REQUEST_VIA_HEADERS -> "header" + AwsSignatureType.HTTP_REQUEST_VIA_QUERY_PARAMS -> "query" + else -> error("Unsupported signature type $this for test suite") + } + +typealias SigningStateProvider = suspend (HttpRequest, AwsSigningConfig) -> String + // FIXME - move to common test (will require ability to access test resources in a KMP compatible way) @TestInstance(TestInstance.Lifecycle.PER_CLASS) // necessary so arg factory methods can handle disabledTests @@ -89,7 +103,17 @@ actual abstract class SigningSuiteTestBase : HasSigner { .map { it.parent } } - protected open val disabledTests = setOf() + protected open val disabledTests = setOf( + // ktor-http-cio parser doesn't support parsing multiline headers since they are deprecated in RFC7230 + "get-header-value-multiline", + // ktor fails to parse with space in it (expects it to be a valid request already encoded) + "get-space-normalized", + "get-space-unnormalized", + + // no signed request to test against + "get-vanilla-query-order-key", + "get-vanilla-query-order-value", + ) fun headerTestArgs(): List = getTests(AwsSignatureType.HTTP_REQUEST_VIA_HEADERS) fun queryTestArgs(): List = getTests(AwsSignatureType.HTTP_REQUEST_VIA_QUERY_PARAMS) @@ -98,7 +122,7 @@ actual abstract class SigningSuiteTestBase : HasSigner { fun testParseRequest() { // sanity test that we are converting requests from file correctly val noBodyTest = testSuitePath.resolve("post-vanilla") - val actual = getSignedRequest(noBodyTest) + val actual = getSignedRequest(noBodyTest, AwsSignatureType.HTTP_REQUEST_VIA_HEADERS) assertEquals(3, actual.headers.names().size) assertIs(actual.body) @@ -110,13 +134,13 @@ actual abstract class SigningSuiteTestBase : HasSigner { ) } - @ParameterizedTest(name = "header test {0} (#{index})") + @ParameterizedTest(name = "header middleware test {0} (#{index})") @MethodSource("headerTestArgs") fun testSigv4TestSuiteHeaders(test: Sigv4TestSuiteTest) { testSigv4Middleware(test) } - @ParameterizedTest(name = "query param test {0} (#{index})") + @ParameterizedTest(name = "query param middleware test {0} (#{index})") @MethodSource("queryTestArgs") fun testSigv4TestSuiteQuery(test: Sigv4TestSuiteTest) { testSigv4Middleware(test) @@ -127,13 +151,12 @@ actual abstract class SigningSuiteTestBase : HasSigner { try { val req = getRequest(dir) val config = getSigningConfig(dir) ?: defaultTestSigningConfig - val sreq = when (signatureType) { - AwsSignatureType.HTTP_REQUEST_VIA_HEADERS -> getSignedRequest(dir) - AwsSignatureType.HTTP_REQUEST_VIA_QUERY_PARAMS -> getQuerySignedRequest(dir) - else -> error("unsupported signature type $signatureType") - } + val canonicalRequest = getCanonicalRequest(dir, signatureType) + val stringToSign = getStringToSign(dir, signatureType) + val signature = getSignature(dir, signatureType) + val signedReq = getSignedRequest(dir, signatureType) config.signatureType = signatureType - Sigv4TestSuiteTest(dir, req, sreq, config.build()) + Sigv4TestSuiteTest(dir, req, canonicalRequest, stringToSign, signature, signedReq, config.build()) } catch (ex: Exception) { println("failed to get request from $dir: ${ex.message}") throw ex @@ -156,6 +179,69 @@ actual abstract class SigningSuiteTestBase : HasSigner { } } + @ParameterizedTest(name = "header canonical request test {0} (#{index})") + @MethodSource("headerTestArgs") + fun testCanonicalRequestHeaders(test: Sigv4TestSuiteTest) { + testCanonicalRequest(test) + } + + @ParameterizedTest(name = "query param canonical request test {0} (#{index})") + @MethodSource("queryTestArgs") + fun testCanonicalRequestQuery(test: Sigv4TestSuiteTest) { + testCanonicalRequest(test) + } + + open val canonicalRequestProvider: SigningStateProvider? = null + + private fun testCanonicalRequest(test: Sigv4TestSuiteTest) = runBlocking { + assumeTrue(canonicalRequestProvider != null) + val expected = test.canonicalRequest + val actual = canonicalRequestProvider!!(test.request.build(), test.config) + assertEquals(expected, actual) + } + + @ParameterizedTest(name = "header signature test {0} (#{index})") + @MethodSource("headerTestArgs") + fun testSignatureHeaders(test: Sigv4TestSuiteTest) { + testSignature(test) + } + + @ParameterizedTest(name = "query param signature test {0} (#{index})") + @MethodSource("queryTestArgs") + fun testSignatureQuery(test: Sigv4TestSuiteTest) { + testSignature(test) + } + + open val signatureProvider: SigningStateProvider? = null + + private fun testSignature(test: Sigv4TestSuiteTest) = runBlocking { + assumeTrue(signatureProvider != null) + val expected = test.signature + val actual = signatureProvider!!(test.request.build(), test.config) + assertEquals(expected, actual) + } + + @ParameterizedTest(name = "header string to sign test {0} (#{index})") + @MethodSource("headerTestArgs") + fun testStringToSignHeaders(test: Sigv4TestSuiteTest) { + testStringToSign(test) + } + + @ParameterizedTest(name = "query param string to sign test {0} (#{index})") + @MethodSource("queryTestArgs") + fun testStringToSignQuery(test: Sigv4TestSuiteTest) { + testStringToSign(test) + } + + open val stringToSignProvider: SigningStateProvider? = null + + private fun testStringToSign(test: Sigv4TestSuiteTest) = runBlocking { + assumeTrue(stringToSignProvider != null) + val expected = test.stringToSign + val actual = stringToSignProvider!!(test.request.build(), test.config) + assertEquals(expected, actual) + } + /** * Get the actual signed request after sending it through middleware * @@ -194,6 +280,8 @@ actual abstract class SigningSuiteTestBase : HasSigner { return operation.context[HttpOperationContext.HttpCallList].last().request } + private fun StringValuesMap.lowerKeys(): Set = entries().map { it.key.lowercase() }.toSet() + private fun assertRequestsEqual(expected: HttpRequest, actual: HttpRequest, message: String? = null) { assertEquals(expected.method, actual.method, message) assertEquals(expected.url.path, actual.url.path, message) @@ -205,6 +293,9 @@ actual abstract class SigningSuiteTestBase : HasSigner { assertEquals(expectedValues, actualValues, "expected header `$key=$expectedValues` in signed request") } + val extraHeaders = actual.headers.lowerKeys() - expected.headers.lowerKeys() + assertEquals(0, extraHeaders.size, "Found extra headers in request: $extraHeaders") + expected.url.parameters.forEach { key, values -> val expectedValues = values.sorted().joinToString(separator = ", ") val actualValues = actual.url.parameters.getAll(key)?.sorted()?.joinToString(separator = ", ") @@ -212,6 +303,9 @@ actual abstract class SigningSuiteTestBase : HasSigner { assertEquals(expectedValues, actualValues, "expected query param `$key=$expectedValues` in signed request") } + val extraParams = actual.url.parameters.lowerKeys() - expected.url.parameters.lowerKeys() + assertEquals(0, extraParams.size, "Found extra query params in request: $extraParams") + when (val expectedBody = expected.body) { is HttpBody.Empty -> assertIs(actual.body) is HttpBody.Bytes -> { @@ -268,20 +362,21 @@ actual abstract class SigningSuiteTestBase : HasSigner { } /** - * Get `header-signed-request.txt` from the given directory [dir] + * Get `-signed-request.txt` from the given directory [dir] */ - private fun getSignedRequest(dir: Path): HttpRequestBuilder { - val path = dir.resolve("header-signed-request.txt") + private fun getSignedRequest(dir: Path, type: AwsSignatureType): HttpRequestBuilder { + val path = dir.resolve("${type.fileNamePart}-signed-request.txt") return parseRequest(path) } - /** - * Get `query-signed-request.txt` from the given directory [dir] - */ - private fun getQuerySignedRequest(dir: Path): HttpRequestBuilder { - val path = dir.resolve("query-signed-request.txt") - return parseRequest(path) - } + private fun getCanonicalRequest(dir: Path, type: AwsSignatureType): String = + dir.resolve("${type.fileNamePart}-canonical-request.txt").readText().normalizeLineEndings() + + private fun getSignature(dir: Path, type: AwsSignatureType): String = + dir.resolve("${type.fileNamePart}-signature.txt").readText().normalizeLineEndings() + + private fun getStringToSign(dir: Path, type: AwsSignatureType): String = + dir.resolve("${type.fileNamePart}-string-to-sign.txt").readText().normalizeLineEndings() /** * Parse a path containing an HTTP request into an in memory representation of an SDK request @@ -372,3 +467,6 @@ private fun buildOperation( set(AwsSigningAttributes.SigningService, config.service) } } + +private val irregularLineEndings = """\r\n?""".toRegex() +private fun String.normalizeLineEndings() = replace(irregularLineEndings, "\n") diff --git a/runtime/hashing/common/src/aws/smithy/kotlin/runtime/hashing/HashFunction.kt b/runtime/hashing/common/src/aws/smithy/kotlin/runtime/hashing/HashFunction.kt index fd584a5a8..d752560fb 100644 --- a/runtime/hashing/common/src/aws/smithy/kotlin/runtime/hashing/HashFunction.kt +++ b/runtime/hashing/common/src/aws/smithy/kotlin/runtime/hashing/HashFunction.kt @@ -24,7 +24,7 @@ interface HashFunction { /** * Update the running hash with [input] bytes. This can be called multiple times. */ - fun update(input: ByteArray) + fun update(input: ByteArray, offset: Int = 0, length: Int = input.size) /** * Finalize the hash computation and return the digest bytes. The hash function will be [reset] after the call is diff --git a/runtime/hashing/common/src/aws/smithy/kotlin/runtime/hashing/Hmac.kt b/runtime/hashing/common/src/aws/smithy/kotlin/runtime/hashing/Hmac.kt new file mode 100644 index 000000000..c6338a690 --- /dev/null +++ b/runtime/hashing/common/src/aws/smithy/kotlin/runtime/hashing/Hmac.kt @@ -0,0 +1,50 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ +package aws.smithy.kotlin.runtime.hashing + +import aws.smithy.kotlin.runtime.util.InternalApi +import kotlin.experimental.xor + +/** + * Calculates the HMAC of a key and message using the given hashing algorithm. + * @param key The cryptographic key to use for the HMAC. This key will be truncated and/or padded as necessary to fit + * the block size of [hashFunction]. + * @param message The message to HMAC. + * @param hashFunction The hashing algorithm to use. + */ +@InternalApi +fun hmac(key: ByteArray, message: ByteArray, hashFunction: HashFunction): ByteArray { + val blockSizedKey = key.resizeToBlock(hashFunction) + + val innerKey = blockSizedKey xor 0x36 + val outerKey = blockSizedKey xor 0x5c + + hashFunction.update(innerKey) + hashFunction.update(message) + val innerHash = hashFunction.digest() + + hashFunction.update(outerKey) + hashFunction.update(innerHash) + return hashFunction.digest() +} + +/** + * Calculates the HMAC of a key and message using the given hashing algorithm. + * @param key The cryptographic key to use for the HMAC. This key will be truncated and/or padded as necessary to fit + * the block size of the hash function provided by [hashSupplier]. + * @param message The message to HMAC. + * @param hashSupplier A supplier that yields a hashing algorithm to use. + */ +@InternalApi +fun hmac(key: ByteArray, message: ByteArray, hashSupplier: HashSupplier): ByteArray = + hmac(key, message, hashSupplier()) + +private fun ByteArray.resizeToBlock(hashFunction: HashFunction): ByteArray { + val blockSize = hashFunction.blockSizeBytes + val truncated = if (size > blockSize) hash(hashFunction) else this + return if (truncated.size < blockSize) truncated.copyOf(blockSize) else truncated +} + +private infix fun ByteArray.xor(byte: Byte) = ByteArray(size) { this[it] xor byte } diff --git a/runtime/hashing/common/test/aws/smithy/kotlin/runtime/hashing/HmacTest.kt b/runtime/hashing/common/test/aws/smithy/kotlin/runtime/hashing/HmacTest.kt new file mode 100644 index 000000000..9f7c2180f --- /dev/null +++ b/runtime/hashing/common/test/aws/smithy/kotlin/runtime/hashing/HmacTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ +package aws.smithy.kotlin.runtime.hashing + +import aws.smithy.kotlin.runtime.util.encodeToHex +import kotlin.test.Test +import kotlin.test.assertEquals + +// Test data adapted from https://en.wikipedia.org/wiki/HMAC#Examples + +private const val PANGRAM = "The quick brown fox jumps over the lazy dog" + +class HmacTest { + @Test + fun testMd5() { + assertEquals("80070713463e7749b90c2dc24911e275", hmacTest("key", PANGRAM, ::Md5)) + } + + @Test + fun testSha1() { + assertEquals("de7c9b85b8b78aa6bc8a7a36f70a90701c9db4d9", hmacTest("key", PANGRAM, ::Sha1)) + } + + @Test + fun testSha256() { + assertEquals( + "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8", + hmacTest("key", PANGRAM, ::Sha256), + ) + + assertEquals( + "5597b93a2843078cbb0c920ae41dfe20f1685e10c67e423c11ab91adfc319d12", + hmacTest(PANGRAM.repeat(2), "message", ::Sha256), + ) + } +} + +private fun hmacTest(key: String, message: String, hmacSupplier: HashSupplier): String = + hmac(key.encodeToByteArray(), message.encodeToByteArray(), hmacSupplier).encodeToHex() diff --git a/runtime/hashing/jvm/src/aws/smithy/kotlin/runtime/hashing/Crc32JVM.kt b/runtime/hashing/jvm/src/aws/smithy/kotlin/runtime/hashing/Crc32JVM.kt index 9f04bd12a..dc0ac7682 100644 --- a/runtime/hashing/jvm/src/aws/smithy/kotlin/runtime/hashing/Crc32JVM.kt +++ b/runtime/hashing/jvm/src/aws/smithy/kotlin/runtime/hashing/Crc32JVM.kt @@ -9,7 +9,7 @@ import java.util.zip.CRC32 actual class Crc32 : Crc32Base() { private val md = CRC32() - override fun update(input: ByteArray) = md.update(input) + override fun update(input: ByteArray, offset: Int, length: Int) = md.update(input, offset, length) override fun digest(): ByteArray { val x = digestValue() diff --git a/runtime/hashing/jvm/src/aws/smithy/kotlin/runtime/hashing/Md5JVM.kt b/runtime/hashing/jvm/src/aws/smithy/kotlin/runtime/hashing/Md5JVM.kt index 38a52fccd..b04c00201 100644 --- a/runtime/hashing/jvm/src/aws/smithy/kotlin/runtime/hashing/Md5JVM.kt +++ b/runtime/hashing/jvm/src/aws/smithy/kotlin/runtime/hashing/Md5JVM.kt @@ -8,7 +8,7 @@ import java.security.MessageDigest actual class Md5 : Md5Base() { private val md = MessageDigest.getInstance("MD5") - override fun update(input: ByteArray) = md.update(input) + override fun update(input: ByteArray, offset: Int, length: Int) = md.update(input, offset, length) override fun digest(): ByteArray = md.digest() override fun reset() = md.reset() } diff --git a/runtime/hashing/jvm/src/aws/smithy/kotlin/runtime/hashing/Sha1JVM.kt b/runtime/hashing/jvm/src/aws/smithy/kotlin/runtime/hashing/Sha1JVM.kt index c099fed80..c5d1cfc3f 100644 --- a/runtime/hashing/jvm/src/aws/smithy/kotlin/runtime/hashing/Sha1JVM.kt +++ b/runtime/hashing/jvm/src/aws/smithy/kotlin/runtime/hashing/Sha1JVM.kt @@ -8,7 +8,7 @@ import java.security.MessageDigest actual class Sha1 : Sha1Base() { private val md = MessageDigest.getInstance("SHA-1") - override fun update(input: ByteArray) = md.update(input) + override fun update(input: ByteArray, offset: Int, length: Int) = md.update(input, offset, length) override fun digest(): ByteArray = md.digest() override fun reset() = md.reset() } diff --git a/runtime/hashing/jvm/src/aws/smithy/kotlin/runtime/hashing/Sha256JVM.kt b/runtime/hashing/jvm/src/aws/smithy/kotlin/runtime/hashing/Sha256JVM.kt index a24d61301..3223d3d0f 100644 --- a/runtime/hashing/jvm/src/aws/smithy/kotlin/runtime/hashing/Sha256JVM.kt +++ b/runtime/hashing/jvm/src/aws/smithy/kotlin/runtime/hashing/Sha256JVM.kt @@ -8,7 +8,7 @@ import java.security.MessageDigest actual class Sha256 : Sha256Base() { private val md = MessageDigest.getInstance("SHA-256") - override fun update(input: ByteArray) = md.update(input) + override fun update(input: ByteArray, offset: Int, length: Int) = md.update(input, offset, length) override fun digest(): ByteArray = md.digest() override fun reset() = md.reset() } diff --git a/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/time/TimestampFormat.kt b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/time/TimestampFormat.kt index 98e14f1d2..32298ce87 100644 --- a/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/time/TimestampFormat.kt +++ b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/time/TimestampFormat.kt @@ -9,12 +9,24 @@ package aws.smithy.kotlin.runtime.time */ enum class TimestampFormat { /** - * ISO-8601/RFC5399 timestamp + * ISO-8601/RFC5399 timestamp including fractional seconds at microsecond precision (e.g., + * "2022-04-25T16:44:13.667307Z") * * Prefers RFC5399 when formatting */ ISO_8601, + /** + * A condensed ISO-8601 date/time format at second-level precision (e.g., "20220425T164413Z") + */ + ISO_8601_CONDENSED, + + /** + * A condensed ISO-8601 date format at day-level precision (e.g., "20220425"). Note that this format is always in + * UTC despite not including an offset identifier in the output. + */ + ISO_8601_CONDENSED_DATE, + /** * RFC-5322/2822/822 IMF timestamp * See: https://tools.ietf.org/html/rfc5322 diff --git a/runtime/runtime-core/common/test/aws/smithy/kotlin/runtime/time/InstantTest.kt b/runtime/runtime-core/common/test/aws/smithy/kotlin/runtime/time/InstantTest.kt index 095ac743e..ba8fdf426 100644 --- a/runtime/runtime-core/common/test/aws/smithy/kotlin/runtime/time/InstantTest.kt +++ b/runtime/runtime-core/common/test/aws/smithy/kotlin/runtime/time/InstantTest.kt @@ -24,6 +24,17 @@ class InstantTest { */ private data class FmtTest(val sec: Long, val ns: Int, val expected: String) + /** + * Conversion from epoch sec/ns to multiple formats of ISO-8601 + */ + private data class Iso8601FmtTest( + val sec: Long, + val ns: Int, + val expectedIso8601: String, + val expectedIso8601Cond: String, + val expectedIso8601CondDate: String, + ) + private val iso8601Tests = listOf( FromTest("2020-11-05T19:22:37+00:00", 1604604157, 0), FromTest("2020-11-05T19:22:37Z", 1604604157, 0), @@ -55,24 +66,34 @@ class InstantTest { } private val iso8601FmtTests = listOf( - FmtTest(1604604157, 0, "2020-11-05T19:22:37Z"), - FmtTest(1604604157, 422_000_000, "2020-11-05T19:22:37.422Z"), - FmtTest(1604604157, 422_000, "2020-11-05T19:22:37.000422Z"), - FmtTest(1604604157, 1, "2020-11-05T19:22:37Z"), - FmtTest(1604604157, 999, "2020-11-05T19:22:37Z"), - FmtTest(1604604157, 1_000, "2020-11-05T19:22:37.000001Z"), - FmtTest(1604602957, 0, "2020-11-05T19:02:37Z"), - FmtTest(1604605357, 0, "2020-11-05T19:42:37Z"), - FmtTest(1604558257, 0, "2020-11-05T06:37:37Z"), - FmtTest(1604650057, 0, "2020-11-06T08:07:37Z") + Iso8601FmtTest(1604604157, 0, "2020-11-05T19:22:37Z", "20201105T192237Z", "20201105"), + Iso8601FmtTest(1604604157, 422_000_000, "2020-11-05T19:22:37.422Z", "20201105T192237Z", "20201105"), + Iso8601FmtTest(1604604157, 422_000, "2020-11-05T19:22:37.000422Z", "20201105T192237Z", "20201105"), + Iso8601FmtTest(1604604157, 1, "2020-11-05T19:22:37Z", "20201105T192237Z", "20201105"), + Iso8601FmtTest(1604604157, 999, "2020-11-05T19:22:37Z", "20201105T192237Z", "20201105"), + Iso8601FmtTest(1604604157, 1_000, "2020-11-05T19:22:37.000001Z", "20201105T192237Z", "20201105"), + Iso8601FmtTest(1604602957, 0, "2020-11-05T19:02:37Z", "20201105T190237Z", "20201105"), + Iso8601FmtTest(1604605357, 0, "2020-11-05T19:42:37Z", "20201105T194237Z", "20201105"), + Iso8601FmtTest(1604558257, 0, "2020-11-05T06:37:37Z", "20201105T063737Z", "20201105"), + Iso8601FmtTest(1604650057, 0, "2020-11-06T08:07:37Z", "20201106T080737Z", "20201106"), + ) + + private val iso8601Forms = mapOf( + TimestampFormat.ISO_8601 to Iso8601FmtTest::expectedIso8601, + TimestampFormat.ISO_8601_CONDENSED to Iso8601FmtTest::expectedIso8601Cond, + TimestampFormat.ISO_8601_CONDENSED_DATE to Iso8601FmtTest::expectedIso8601CondDate, ) + @Test fun testFormatAsIso8601() { for ((idx, test) in iso8601FmtTests.withIndex()) { - val actual = Instant - .fromEpochSeconds(test.sec, test.ns) - .format(TimestampFormat.ISO_8601) - assertEquals(test.expected, actual, "test[$idx]: failed to correctly format Instant from") + for ((format, getter) in iso8601Forms) { + val actual = Instant + .fromEpochSeconds(test.sec, test.ns) + .format(format) + val expected = getter(test) + assertEquals(expected, actual, "test[$idx]: failed to correctly format Instant as $format") + } } } diff --git a/runtime/runtime-core/jvm/src/aws/smithy/kotlin/runtime/time/InstantJVM.kt b/runtime/runtime-core/jvm/src/aws/smithy/kotlin/runtime/time/InstantJVM.kt index 0fcb9af09..7db794cb0 100644 --- a/runtime/runtime-core/jvm/src/aws/smithy/kotlin/runtime/time/InstantJVM.kt +++ b/runtime/runtime-core/jvm/src/aws/smithy/kotlin/runtime/time/InstantJVM.kt @@ -11,6 +11,7 @@ package aws.smithy.kotlin.runtime.time // See: https://developer.android.com/studio/write/java8-support-table import java.time.LocalDateTime +import java.time.ZoneId import java.time.ZoneOffset import java.time.ZonedDateTime import java.time.chrono.IsoChronology @@ -61,6 +62,8 @@ actual class Instant(internal val value: jtInstant) : Comparable { */ actual fun format(fmt: TimestampFormat): String = when (fmt) { TimestampFormat.ISO_8601 -> ISO_INSTANT.format(value.truncatedTo(ChronoUnit.MICROS)) + TimestampFormat.ISO_8601_CONDENSED -> ISO_8601_CONDENSED.format(value) + TimestampFormat.ISO_8601_CONDENSED_DATE -> ISO_8601_CONDENSED_DATE.format(value) TimestampFormat.RFC_5322 -> RFC_5322_FIXED_DATE_TIME.format(ZonedDateTime.ofInstant(value, ZoneOffset.UTC)) TimestampFormat.EPOCH_SECONDS -> { val sb = StringBuffer("$epochSeconds") @@ -81,6 +84,16 @@ actual class Instant(internal val value: jtInstant) : Comparable { private val RFC_5322_FIXED_DATE_TIME: DateTimeFormatter = buildRfc5322Formatter() + private val utcZone = ZoneId.of("Z") + + private val ISO_8601_CONDENSED: DateTimeFormatter = DateTimeFormatter + .ofPattern("yyyyMMdd'T'HHmmss'Z'") + .withZone(utcZone) + + private val ISO_8601_CONDENSED_DATE: DateTimeFormatter = DateTimeFormatter + .ofPattern("yyyyMMdd") + .withZone(utcZone) + /** * Parse an ISO-8601 formatted string into an [Instant] */ diff --git a/runtime/serde/serde-json/common/src/aws/smithy/kotlin/runtime/serde/json/JsonSerializer.kt b/runtime/serde/serde-json/common/src/aws/smithy/kotlin/runtime/serde/json/JsonSerializer.kt index d91b26589..de6dfa285 100644 --- a/runtime/serde/serde-json/common/src/aws/smithy/kotlin/runtime/serde/json/JsonSerializer.kt +++ b/runtime/serde/serde-json/common/src/aws/smithy/kotlin/runtime/serde/json/JsonSerializer.kt @@ -246,7 +246,10 @@ class JsonSerializer : Serializer, ListSerializer, MapSerializer, StructSerializ when (format) { TimestampFormat.EPOCH_SECONDS -> jsonWriter.writeRawValue(value.format(format)) TimestampFormat.ISO_8601, - TimestampFormat.RFC_5322 -> jsonWriter.writeValue(value.format(format)) + TimestampFormat.ISO_8601_CONDENSED, + TimestampFormat.ISO_8601_CONDENSED_DATE, + TimestampFormat.RFC_5322, + -> jsonWriter.writeValue(value.format(format)) } } } diff --git a/runtime/utils/common/src/aws/smithy/kotlin/runtime/util/text/Text.kt b/runtime/utils/common/src/aws/smithy/kotlin/runtime/util/text/Text.kt index c53e8f357..3850daeb9 100644 --- a/runtime/utils/common/src/aws/smithy/kotlin/runtime/util/text/Text.kt +++ b/runtime/utils/common/src/aws/smithy/kotlin/runtime/util/text/Text.kt @@ -95,6 +95,38 @@ fun String.encodeUrlPath(validDelimiters: Set, checkPercentEncoded: Boolea return sb.toString() } +/** + * Normalizes the segments of a URL path according to the following rules: + * * The returned path always begins with `/` (e.g., `a/b/c` → `/a/b/c`) + * * The returned path ends with `/` if the input path also does + * * Empty segments are discarded (e.g., `/a//b` → `/a/b`) + * * Segments of `.` are discarded (e.g., `/a/./b` → `/a/b`) + * * Segments of `..` are used to discard ancestor paths (e.g., `/a/b/../c` → `/a/c`) + * * All other segments are unmodified + */ +@InternalApi +public fun String.normalizePathSegments(): String { + val segments = split("/").filter(String::isNotEmpty) + var skip = 0 + val normalizedSegments = buildList { + segments.asReversed().forEach { + when { + it == "." -> { } // Ignore + it == ".." -> skip++ + skip > 0 -> skip-- + else -> add(it) + } + } + }.asReversed() + require(skip == 0) { "Found too many `..` instances for path segment count" } + + return normalizedSegments.joinToString( + separator = "/", + prefix = "/", + postfix = if (normalizedSegments.isNotEmpty() && endsWith("/")) "/" else "", + ) +} + private const val upperHex: String = "0123456789ABCDEF" private val upperHexSet = upperHex.toSet() @@ -130,12 +162,52 @@ public fun String.splitAsQueryString(): Map> { return entries } -private val percentEncodedParam = """%([A-Fa-f0-9]{2})""".toRegex() - /** * Decode a URL's query string, resolving percent-encoding (e.g., "%3B" → ";"). */ @InternalApi -public fun String.urlDecodeComponent(): String = this - .replace('+', ' ') - .replace(percentEncodedParam) { it.groupValues[1].toInt(radix = 16).toChar().toString() } +public fun String.urlDecodeComponent(): String { + val orig = this + return buildString(orig.length) { + var byteBuffer: ByteArray? = null // Do not initialize unless needed + var i = 0 + var c: Char + while (i < orig.length) { + c = orig[i] + when (c) { + '+' -> { + append(' ') + i++ + } + + '%' -> { + if (byteBuffer == null) { + byteBuffer = ByteArray((orig.length - i) / 3) // Max remaining percent-encoded bytes + } + + var byteCount = 0 + while ((i + 2) < orig.length && c == '%') { + val byte = orig.substring(i + 1, i + 3).toInt(radix = 16).toByte() + byteBuffer[byteCount++] = byte + + i += 3 + if (i < orig.length) c = orig[i] + } + + require(i == orig.length || c != '%') { "Incomplete escape pattern at end of string" } + + append(byteBuffer.decodeToString(endIndex = byteCount)) + } + + else -> { + append(c) + i++ + } + } + } + } +} + +@InternalApi +public fun String.urlReencodeComponent(formUrlEncode: Boolean = false): String = + urlDecodeComponent().urlEncodeComponent(formUrlEncode) diff --git a/runtime/utils/common/test/aws/smithy/kotlin/runtime/util/text/TextTest.kt b/runtime/utils/common/test/aws/smithy/kotlin/runtime/util/text/TextTest.kt index b4516ee01..075e69abd 100644 --- a/runtime/utils/common/test/aws/smithy/kotlin/runtime/util/text/TextTest.kt +++ b/runtime/utils/common/test/aws/smithy/kotlin/runtime/util/text/TextTest.kt @@ -8,6 +8,7 @@ package aws.smithy.kotlin.runtime.util.text import io.kotest.matchers.maps.shouldContain import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith data class EscapeTest(val input: String, val expected: String, val formUrlEncode: Boolean = false) @@ -68,6 +69,38 @@ class TextTest { assertEquals("/wikipedia/en/6/61/Purdue_University_%E2%80%93seal.svg", urlPath.encodeUrlPath()) } + @Test + fun testNormalizePathSegments() { + fun assertNormalize(unnormalized: String, expected: String) { + val actual = unnormalized.normalizePathSegments() + assertEquals(expected, actual, "Unexpected normalization for `$unnormalized`") + } + + val tests = mapOf( + "" to "/", + "/" to "/", + "foo" to "/foo", + "/foo" to "/foo", + "foo/" to "/foo/", + "/foo/" to "/foo/", + "/a/b/c" to "/a/b/c", + "/a/b/../c" to "/a/c", + "/a/./c" to "/a/c", + "/./" to "/", + "/a/b/./../c" to "/a/c", + "/a/b/c/d/../e/../../f/../../../g" to "/g", + "//a//b//c//" to "/a/b/c/", + ) + tests.forEach { (unnormalized, expected) -> assertNormalize(unnormalized, expected) } + } + + @Test + fun testNormalizePathSegmentsError() { + assertFailsWith(IllegalArgumentException::class) { + "/a/b/../../..".normalizePathSegments() + } + } + @Test fun utf8UrlPathValuesEncodeCorrectly() { val swissAndGerman = "\u0047\u0072\u00fc\u0065\u007a\u0069\u005f\u007a\u00e4\u006d\u00e4" @@ -115,8 +148,15 @@ class TextTest { @Test fun decodeUrlComponent() { - val component = "a%3Bb+c%7Ed%20e%2Bf" - val expected = "a;b c~d e+f" + val component = "a%3Bb+c%7Ed%20e%2Bf+g%3D%E1%88%B4" + val expected = "a;b c~d e+f g=ሴ" assertEquals(expected, component.urlDecodeComponent()) } + + @Test + fun reencodeUrlComponent() { + val component = "ሴ%E1%88%B4" + val expected = "%E1%88%B4%E1%88%B4" + assertEquals(expected, component.urlReencodeComponent()) + } } diff --git a/settings.gradle.kts b/settings.gradle.kts index dc2dd8493..7b2c9138b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -35,6 +35,7 @@ include(":runtime") include(":runtime:auth:aws-credentials") include(":runtime:auth:aws-signing-common") include(":runtime:auth:aws-signing-crt") +include(":runtime:auth:aws-signing-default") include(":runtime:auth:aws-signing-tests") include(":runtime:crt-util") include(":runtime:hashing") diff --git a/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/KotlinDependency.kt b/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/KotlinDependency.kt index e29b30f47..e76c95664 100644 --- a/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/KotlinDependency.kt +++ b/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/KotlinDependency.kt @@ -86,7 +86,7 @@ data class KotlinDependency( val AWS_CRT_HTTP_ENGINE = KotlinDependency(GradleConfiguration.Implementation, "$RUNTIME_ROOT_NS.http.engine.crt", RUNTIME_GROUP, "http-client-engine-crt", RUNTIME_VERSION) val AWS_CREDENTIALS = KotlinDependency(GradleConfiguration.Implementation, "$RUNTIME_ROOT_NS.auth.awscredentials", RUNTIME_GROUP, "aws-credentials", RUNTIME_VERSION) val AWS_SIGNING_COMMON = KotlinDependency(GradleConfiguration.Implementation, "$RUNTIME_ROOT_NS.auth.awssigning", RUNTIME_GROUP, "aws-signing-common", RUNTIME_VERSION) - val AWS_SIGNING_CRT = KotlinDependency(GradleConfiguration.Implementation, "$RUNTIME_ROOT_NS.auth.awssigning.crt", RUNTIME_GROUP, "aws-signing-crt", RUNTIME_VERSION) + val AWS_SIGNING_DEFAULT = KotlinDependency(GradleConfiguration.Implementation, "$RUNTIME_ROOT_NS.auth.awssigning", RUNTIME_GROUP, "aws-signing-default", RUNTIME_VERSION) // External third-party dependencies val KOTLIN_TEST = KotlinDependency(GradleConfiguration.TestImplementation, "kotlin.test", "org.jetbrains.kotlin", "kotlin-test", KOTLIN_COMPILER_VERSION) diff --git a/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/RuntimeTypes.kt b/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/RuntimeTypes.kt index 747d93cd2..ac960ed38 100644 --- a/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/RuntimeTypes.kt +++ b/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/RuntimeTypes.kt @@ -206,8 +206,8 @@ object RuntimeTypes { val SigningEndpointProvider = runtimeSymbol("SigningEndpointProvider", KotlinDependency.AWS_SIGNING_COMMON) } - object AwsSigningCrt { - val CrtAwsSigner = runtimeSymbol("CrtAwsSigner", KotlinDependency.AWS_SIGNING_CRT) + object AwsSigningStandard { + val DefaultAwsSigner = runtimeSymbol("DefaultAwsSigner", KotlinDependency.AWS_SIGNING_DEFAULT) } } } diff --git a/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/signing/AwsSignerIntegration.kt b/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/signing/AwsSignerIntegration.kt index 1d9f0dd6b..0f67b9658 100644 --- a/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/signing/AwsSignerIntegration.kt +++ b/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/signing/AwsSignerIntegration.kt @@ -20,9 +20,9 @@ class AwsSignerIntegration : KotlinIntegration { symbol = RuntimeTypes.Auth.Signing.AwsSigningCommon.AwsSigner name = "signer" documentation = "The implementation of AWS signer to use for signing requests" - propertyType = ClientConfigPropertyType.RequiredWithDefault("CrtAwsSigner") + propertyType = ClientConfigPropertyType.RequiredWithDefault("DefaultAwsSigner") additionalImports = listOf( - RuntimeTypes.Auth.Signing.AwsSigningCrt.CrtAwsSigner, + RuntimeTypes.Auth.Signing.AwsSigningStandard.DefaultAwsSigner, ) }, )