Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: initial implementation of standard AWS signer #635

Merged
merged 7 commits into from
May 9, 2022

Conversation

ianbotsf
Copy link
Contributor

@ianbotsf ianbotsf commented May 3, 2022

Issue #

Addresses #617

Description of changes

This change introduces a new native Kotlin (i.e., non-CRT) implementation of SigV4 signing (called the "standard" signer). The new signer passes all the tests that the CRT signer passes except for SigV4a tests. (Support for SigV4a will come later.)

This change also adds support for HMAC calculation and fixes a Unicode bug with decodeUrlComponent.

Companion PR: aws-sdk-kotlin#600

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

Copy link
Contributor

@aajtodd aajtodd left a comment

Choose a reason for hiding this comment

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

Overall looks great! Fantastic work. Just a few comments/questions.

/**
* A condensed ISO-8601 date/time format at second-level precision (e.g., "20220425T164413Z")
*/
ISO_8601_CONDENSED,
Copy link
Contributor

Choose a reason for hiding this comment

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

fix: missing tests for these new formats


// Test adapted from https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html
@Test
fun testStringToSign() {
Copy link
Contributor

Choose a reason for hiding this comment

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

fix

This is great as a simple sanity test but the test suite has the string to sign for both:

The test suite also includes the canonical requests for both forms.

We should integrate the string to sign and the canonical request into the test suite. Obviously for CRT we can't enable this as it's a black box but we should be able to for our signer. This will provide a lot more coverage and help isolate any issues in future/add new test 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.

Yeah, enhancing the test suite is a good idea. Will do and then drop any effectively-duplicate hand-written tests.

import aws.smithy.kotlin.runtime.time.TimestampFormat

/** The standard implementation of [AwsSigner] */
val StandardAwsSigner: AwsSigner = StandardAwsSignerImpl()
Copy link
Contributor

Choose a reason for hiding this comment

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

should we consider naming this DefaultAwsSigner to match the existing terminology we have chosen for similar things? (e.g. DefaultHttpEngine). The artifact would be aws-signing-default, etc.

In which case we would just extend the base namespace rather than adding a .standard to the namespace

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, we can do that. I wanted to call it "Default" originally but then couldn't easily use default as a namespace because it's a Java keyword. But if we just put it in the parent (i.e., awssigning) namespace that takes care of that.

/**
* An object that can canonicalize a request.
*/
internal interface Canonicalizer {
Copy link
Contributor

Choose a reason for hiding this comment

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

is there a reason we need this to be an interface with a Default on the companion object?

Same question for RequestMutator, SignatureCalculator, etc.

Clearly this provides an extension point to override these components but these all seem like internal details to begin with that I'm not sure why we need the extra abstraction.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, they're definitely internal details but I wanted the logic broken out into composable components so I could test them in isolation and then test the combination of them all within the signer.

Copy link
Contributor

Choose a reason for hiding this comment

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

For sure I think having them as separate components is fine, I was more just questioning the need for the interface vs type separation. I may have missed it but I didn't even see where we plug in a different implementation in tests. It's an overall minor point though

)

internal class DefaultCanonicalizer(private val sha256Supplier: HashSupplier = ::Sha256) : Canonicalizer {
private val logger = Logger.getLogger<DefaultCanonicalizer>()
Copy link
Contributor

Choose a reason for hiding this comment

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

should we consider having all signing related logs come from a single name/logger (e.g. aws.smithy.kotlin.runtime.auth.awssigning.AwsSigner)? I know that you can filter logs by name, this would enable isolating just signing related logs. If we have multiple logger names (e.g. DefaultCanonicalizer, DefaultSignatureCalculator, etc) it will be harder to isolate signing related logs.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh yes, good point. I'll change the logger reference to the signer class name.

}

param("Host", builder.url.host, !(signViaQueryParams || "Host" in params))
param("X-Amz-Algorithm", ALGORITHM_NAME, signViaQueryParams)
Copy link
Contributor

Choose a reason for hiding this comment

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

does algorithm name not come from config?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It could probably be associated with AwsSigningAlgorithm. Not yet sure if SigV4a will have a single algorithm name or will use different ones depending on conditions. This code is likely to change once there's a second algorithm to support.

* @return The hash as a hex string
*/
private suspend fun HttpBody.calculateHash(): String = when (this) {
is HttpBody.Empty -> BodyHash.EmptyBody.hash!!
Copy link
Contributor

Choose a reason for hiding this comment

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

we could probably change the definition of these to e.g. object EmptyBody(override val hash: String = <constant>: BodyHash(hash) which would make unwrapping unnecessary when you know you have an explicit hash

while (!isClosedForRead || availableForRead > 0) {
val bytesRead = readAvailable(sink)
if (bytesRead <= 0) break
hash.update(sink.sliceArray(0 until bytesRead))
Copy link
Contributor

Choose a reason for hiding this comment

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

is sliceArray smart enough to not copy if it's the entire array? If not we may consider predicating on whether we filled the entire array or not to do a slice and avoid an extra copy

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No, it delegates down to copyOfRange which explicitly returns a copy (in case you want to modify it or something). I'll add an optimization 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.

Actually, I realized there's no reason to make a copy of this array even in the case that bytesRead < STREAM_CHUNK_BYTES. It's inefficient always.

A better change is to expand HashFunction to allow updating based on a subset of the array rather than requiring the whole array be used. I'll make that change instead.


val stringToSign = signatureCalculator.stringToSign(canonical.requestString, config)
val signingKey = signatureCalculator.signingKey(config, credentials)
val signature = signatureCalculator.calculate(signingKey, stringToSign)
Copy link
Contributor

Choose a reason for hiding this comment

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

looks like we log the string to sign, should we also log the signature?

@@ -89,7 +90,17 @@ actual abstract class SigningSuiteTestBase : HasSigner {
.map { it.parent }
}

protected open val disabledTests = setOf<String>()
protected open val disabledTests = setOf<String>(
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd love to get these enabled as they are limitations on the parser not on our signer (theoretically). It doesn't need to be part of this PR though necessarily

appendLine(config.signingDate.format(TimestampFormat.ISO_8601_CONDENSED))
appendLine(config.credentialScope)
append(canonicalRequest.encodeToByteArray().hash(sha256Provider).encodeToHex())
}.also { logger.debug { "String to sign:\n$it" } }
Copy link
Contributor

Choose a reason for hiding this comment

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

question: Should signing related logs be gated by an explicit SdkLogMode ? 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

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

They're not gated in CRT nor in Java v2 by anything other than logging level. I don't believe the things we're logging (or would ever log) include sensitive information...just intermediate state. So my instinct was no additional gating is necessary but happy to counterarguments.

Copy link
Contributor

Choose a reason for hiding this comment

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

Nope more of a sanity check that we gave it consideration

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
Copy link
Contributor

Choose a reason for hiding this comment

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

IMO it's worth considering a reduce() here over n iterations on the set of headers, both to simplify and because this is a runtime thing and it'll be per request (not that the list of headers is particularly large, but yeah).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The code for reduce wound up looking too wonky. Instead, I added an asSequence() call to turn the entries set into a lazy collection. Thus, future transformations are lazy until some terminal operation.

@@ -206,8 +206,8 @@ object RuntimeTypes {
val SigningEndpointProvider = runtimeSymbol("SigningEndpointProvider", KotlinDependency.AWS_SIGNING_COMMON)
}

object AwsSigningCrt {
Copy link
Contributor

Choose a reason for hiding this comment

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

Shouldn't we remove the CRT implementation entirely? ie. runtime/auth/aws-signing-crt

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No we intend to offer the CRT as an alternate signing implementation for the foreseeable future. Currently, for example, the new implementation doesn't support SigV4a. There may be other situations in which people want (or need) to use the CRT here.

…pport hashing partial arrays; more tests and logging for intermediate signing steps; rework body hash classes
Copy link
Contributor

@aajtodd aajtodd left a comment

Choose a reason for hiding this comment

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

Overall looks great!

extra["displayName"] = "Smithy :: Kotlin :: Standard AWS Signer"
extra["moduleName"] = "aws.smithy.kotlin.runtime.auth.awssigning.standard"
extra["moduleName"] = "aws.smithy.kotlin.runtime.auth.awssigning.default"
Copy link
Contributor

Choose a reason for hiding this comment

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

extraneous default

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'm not sure what moduleName is used for. Our aws-signing-common subproject already uses aws.smithy.kotlin.runtime.auth.signing.awssigning as its moduleName. Will there be a problem if these two are the same?

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think so but I'm not positive. These aren't really required and we could probably stop setting them all together is my guess.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Looks like they're not required to be unique.

private val canonicalizer: Canonicalizer = Canonicalizer.Default,
private val signatureCalculator: SignatureCalculator = SignatureCalculator.Default,
private val requestMutator: RequestMutator = RequestMutator.Default,
) : AwsSigner {
private val logger = Logger.getLogger<DefaultAwsSignerImpl>()
Copy link
Contributor

Choose a reason for hiding this comment

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

wonder if we can have this off AwsSigner interface 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.

We can but I don't think that's a typical convention for Slf4j logging. What benefit would that confer? Would there be any downsides if there were two simultaneous clients using different signer implementations?

Copy link
Contributor

Choose a reason for hiding this comment

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

well I mean slf4j will only be JVM so I don't really care about that convention but as to why it's simply preference. I don't like having our logs output strings with aws.smithy.kotlin.runtime.auth.awssigning.DefaultAwsSignerImpl, it's like leaking an abstraction. I much prefer something like aws.smithy.kotlin.runtime.auth.awssigning.AwsSigner.

That being said I get what you're saying and I don't feel strongly here, just irks me for some reason.


val credentials = config.credentialsProvider.getCredentials()

val canonical = canonicalizer.canonicalRequest(request, config, credentials)
logger.debug { "Canonical request:\n${canonical.requestString}" }
Copy link
Contributor

Choose a reason for hiding this comment

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

should these be debug or trace? I don't have a strong preference either way, just bringing it up for discussion

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh yeah trace makes more sense for the intermediate signing stages. We can probably still keep a single debug message for the final signing result.

@sonarqubecloud
Copy link

sonarqubecloud bot commented May 9, 2022

Kudos, SonarCloud Quality Gate passed!    Quality Gate passed

Bug A 0 Bugs
Vulnerability A 0 Vulnerabilities
Security Hotspot A 0 Security Hotspots
Code Smell A 4 Code Smells

No Coverage information No Coverage information
0.0% 0.0% Duplication

@ianbotsf ianbotsf merged commit 5be175d into feat-standard-signer May 9, 2022
@ianbotsf ianbotsf deleted the standard-signer-initial-impl branch May 9, 2022 19:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants