Add AWS http request signer#5580
Add AWS http request signer#5580htuch merged 7 commits intoenvoyproxy:masterfrom lavignes:aws-sigv4-signer
Conversation
Signed-off-by: Scott LaVigne <lavignes@amazon.com>
htuch
left a comment
There was a problem hiding this comment.
Looks great in general, some comments on structure, implementation and Envoy style. This is definitely heading in the right direction, thanks for splitting up the PRs!
| envoy_package() | ||
|
|
||
| envoy_cc_library( | ||
| name = "signer_lib", |
There was a problem hiding this comment.
Nit: if this is going to only ever be a pure interface, signer_interface would be more consistent with other conventions.
There was a problem hiding this comment.
Good to know. I thought that convention was only used the public headers. e.g. include/envoy.
|
|
||
| const absl::optional<std::string>& secretAccessKey() const { return secret_access_key_; } | ||
|
|
||
| void setSessionToken(const std::string& session_token) { |
There was a problem hiding this comment.
Do we need both setters and constructors for these fields? Could this be an immutable object?
There was a problem hiding this comment.
We don't need them. I can remove them.
|
|
||
| ~Credentials() = default; | ||
|
|
||
| Credentials(const std::string& access_key_id, const std::string& secret_access_key) |
There was a problem hiding this comment.
Nit: we tend to use absl::string_view rather than const std::string& in new code, as it's strictly more general. You can still turn this into a string copy below.
There was a problem hiding this comment.
clang-tidy will suggest pass by value here and std::move below for constructors like this.
https://clang.llvm.org/extra/clang-tidy/checks/modernize-pass-by-value.html
By doing this you may save one copy if caller does move too.
| public: | ||
| Credentials() = default; | ||
|
|
||
| ~Credentials() = default; |
There was a problem hiding this comment.
Nit: probably don't need this if we're not doing polymorphic inheritance.
There was a problem hiding this comment.
Removed the destructor, but I do use the no-args constructor.
| namespace Common { | ||
| namespace Aws { | ||
|
|
||
| class RegionProvider { |
There was a problem hiding this comment.
I had a look at how this works in #5546. This allows both static and environment to specify region. I guess my main question is "do we really need this?". Specifically, Envoy's Node ID includes locality information (and a variety of ways to specify, from bootstrap config to CLI). Can we pull region from that in the interest of economy of configuration mechanism and making this more consistent with the rest of Envoy config?
There was a problem hiding this comment.
My intention was to eventually add a region provider impl that pulls the region from an AWS-endpoint, much like we pull the credentials.
There was a problem hiding this comment.
I guess the larger point is "shouldn't we be synthesizing the Node locality in the bootstrap or CLI for Envoys based on the AWS endpoint data source?" If you do that, there is no need to manage data sources for this information in the extension.
Credentials can also be placed in Node metadata, but the limitation there is that if they change dynamically during the lifetime of the Envoy, they can become stale. For region, this should be static for the lifetime of an Envoy.
There was a problem hiding this comment.
Sorry for not addressing that larger point. I think it makes sense. I'll pipe the node metadata through in the later PR. I'll eliminate this class, especially since it will probably just provide static data for the foreseeable future.
| // Path | ||
| const auto* path_header = headers.Path(); | ||
| if (path_header == nullptr || path_header->value().empty()) { | ||
| throw EnvoyException("Message is missing :path header"); |
There was a problem hiding this comment.
I don't think either the method or path should be missing by construction, so you can probably omit these.
There was a problem hiding this comment.
Actually there doesn't seem to be anything preventing someone from not setting the path or method. Due to the way I plan to use this in the gRPC credentials plugin, I'd like to ensure these are set instead of seg-faulting.
There was a problem hiding this comment.
Yeah, I was thinking on the request receive path, but here you are creating a new request.
| } | ||
| out << "\n" << signing_headers << "\n"; | ||
| // Content Hash | ||
| out << content_hash; |
There was a problem hiding this comment.
Might be slightly more readable to use fmt::format here instead of streams, but up to you if you prefer streams.
There was a problem hiding this comment.
+1, IIRC fmt::format is much faster than streams, if this is potentially being used in data path, format is better.
There was a problem hiding this comment.
This code is mostly just "\n" joining a bunch of strings. Though it is clearly not evident :). I'm updating it to use absl::StrJoin instead.
| } | ||
|
|
||
| std::map<std::string, std::string> | ||
| SignerImpl::canonicalizeHeaders(const Http::HeaderMap& headers) const { |
There was a problem hiding this comment.
The logic in here is quite arcane; do you think we could split this out of a private method into a standalone utility and write some extensive tests for this? It seems a possible source of compliance bugs.
There was a problem hiding this comment.
Sure. It is one of the trickier parts of the code. And even the public documentation of the canonicalization process glosses over some details.
| RELEASE_ASSERT(len <= EVP_MAX_MD_SIZE, "Hmac length too large"); | ||
| HMAC_CTX_cleanup(&ctx); | ||
| mac.resize(len); | ||
| return mac; |
There was a problem hiding this comment.
I think hmac and hash are general enough to put in source/common/ssl/utility.cc or somewhere like that, this seems like something other folks might want to reuse.
| message_->headers().Authorization()->value().c_str()); | ||
| } | ||
|
|
||
| TEST_F(SignerImplTest, ContentHeader) { |
There was a problem hiding this comment.
Can you add short // explanations of what each of these tests does above each TEST_F?
|
|
||
| #include "extensions/filters/http/common/aws/credentials_provider.h" | ||
| #include "extensions/filters/http/common/aws/region_provider.h" | ||
| #include "extensions/filters/http/common/aws/signer.h" |
There was a problem hiding this comment.
Would it make sense to have an abstract signing API in include/envoy somewhere, and have AwsSigner implement that as an extension?
There was a problem hiding this comment.
I think this makes sense if we want a generic request signing extension. If @lavignes wants to take that on in this PR series, that's fine, otherwise we could leave a TODO and do the refactor when we have a second signing extension that wants to do something similar.
There was a problem hiding this comment.
I opted for the TODO. It'd be trivial to expose it in a public header in the future.
* Use absl::string_view more aggresively * Removing Aws::RegionProvider * Move SHA-256 Digest/HMAC utils to common/ssl/utility * Removed setters from Aws::Credentials * Remove usage of stringstream * Use ConstSingleton for string constants * Add test comments Signed-off-by: Scott LaVigne <lavignes@amazon.com>
htuch
left a comment
There was a problem hiding this comment.
This is looking great, just some minor stuff and would like @PiotrSikora to take a quick pass on the BoringSSL side.
| // Path | ||
| const auto* path_header = headers.Path(); | ||
| if (path_header == nullptr || path_header->value().empty()) { | ||
| throw EnvoyException("Message is missing :path header"); |
There was a problem hiding this comment.
Yeah, I was thinking on the request receive path, but here you are creating a new request.
source/common/ssl/utility.cc
Outdated
| return std::chrono::system_clock::from_time_t(days * 24 * 60 * 60 + seconds); | ||
| } | ||
|
|
||
| std::vector<uint8_t> Utility::getSha256Digest(const Buffer::Instance& buffer) { |
There was a problem hiding this comment.
@PiotrSikora can you do a review pass on these two functions and their tests from a BoringSSL perspective? Thanks.
source/common/ssl/utility.cc
Outdated
| unsigned int len; | ||
| rc = HMAC_Final(&ctx, mac.data(), &len); | ||
| RELEASE_ASSERT(rc == 1, "Failed to finalize HMAC"); | ||
| RELEASE_ASSERT(len <= EVP_MAX_MD_SIZE, "HMAC length too large"); |
There was a problem hiding this comment.
Is this best an ASSERT? I.e. it can never happen? Same with the other length check above?
There was a problem hiding this comment.
I followed the conventions of the other parts of envoy that used these types of SSL apis. I think these, for all intents and purposes, don't happen.
| parts.emplace_back(signing_headers); | ||
| parts.emplace_back(content_hash); | ||
| return absl::StrJoin(parts, "\n"); | ||
| } |
There was a problem hiding this comment.
This one might also belong in the utility like canonical headers creation, it has some edge cases that would benefit from unit tests unless already well covered.
There was a problem hiding this comment.
I do have tests in the signer_tests specifically for this function but I can pull it out into a utility. I'm not sure what the convention is on util creation in envoy. Is it for any one function that is sufficiently complex on its own? Or is it just about reuse? I don't really see the request or header canonicalization being used outside of signing AWS requests.
There was a problem hiding this comment.
It's kind of up to you largely, as long as you can provide adequate test coverage. The philosophy is at a bare minimum we have code coverage based on lines, but for complex/tricky code, it's often useful to ensure we can do behavioral/branch/functional coverage via unit tests. Some folks use testing peers, others code structure to do this. I personally don't like moving private to public methods in a class just for test, but I do like adding a logically separate utility class that has the methods as public where it makes sense.
| DateFormatter long_date_formatter_; | ||
| DateFormatter short_date_formatter_; | ||
|
|
||
| std::string createContentHash(Http::Message& message) const; |
There was a problem hiding this comment.
Nit: generally we prefer methods before data members in each access scoped section, e.g. private here.
| class Utility { | ||
| public: | ||
| /** | ||
| * Creates a canonicalized header map according to |
There was a problem hiding this comment.
Maybe explain why we are doing this; it's just for signing, right? We never put these on the wire..
There was a problem hiding this comment.
The whole canonicalization process is at the end of the day about consistently hashing HTTP requests. We do the exact same canonicalization upstream as part of our AuthN. This map is really just part of the input string to the hash we put in the Authorization header.
I'll explicitly say this is to be used as an input to the request canonicalization function. (Which I'll also move into this util)
| [](const Http::HeaderEntry& entry, void* context) -> Http::HeaderMap::Iterate { | ||
| auto* map = static_cast<std::map<std::string, std::string>*>(context); | ||
| // Pseudo-headers should not be canonicalized | ||
| if (entry.key().c_str()[0] == ':') { |
There was a problem hiding this comment.
Maybe an empty header in there somehow as well?
| } | ||
| return Http::HeaderMap::Iterate::Continue; | ||
| }, | ||
| &out); |
There was a problem hiding this comment.
I have a feeling this is more generally useful (without the quirks below), but it's fine to leave here, just thinking out loud.
| } else { | ||
| out.emplace(Http::Headers::get().HostLegacy.get(), std::string(value)); | ||
| } | ||
| } |
There was a problem hiding this comment.
I'm going to completely trust you on the business logic here, code looks good.
source/common/common/logger.h
Outdated
| // clang-format off | ||
| #define ALL_LOGGER_IDS(FUNCTION) \ | ||
| FUNCTION(admin) \ | ||
| FUNCTION(aws) \ |
There was a problem hiding this comment.
Is there any reason not to use http?
There was a problem hiding this comment.
Given where the code is now, it could just be classified as http. I'm fine doing that. :)
| namespace Common { | ||
| namespace Aws { | ||
|
|
||
| void SignerImpl::sign(Http::Message& message) { |
There was a problem hiding this comment.
This whole function assumes that the complete request body is available at this point. I'm not sure how are you going to use this, but Envoy doesn't buffer requests before forwarding them, so this won't work for proxied requests.
There was a problem hiding this comment.
That is a good point. In the sigv4 algorithm, the request body signing is technically optional (for this very reason), but adds an extra layer of security for certain types of requests. I'll add a parameter, much like we do in the official SDK to control signing the body. The caveat being, that the body must be fully available at that point.
source/common/ssl/utility.cc
Outdated
| unsigned int digest_length; | ||
| rc = EVP_DigestFinal(&ctx, digest.data(), &digest_length); | ||
| RELEASE_ASSERT(rc == 1, "Failed to finalize digest"); | ||
| RELEASE_ASSERT(digest_length == SHA256_DIGEST_LENGTH, "Digest length mismatch"); |
There was a problem hiding this comment.
I know we check this in a few other places, but it turns out this isn't necessary.
source/common/ssl/utility.cc
Outdated
| } | ||
|
|
||
| std::vector<uint8_t> Utility::getSha256Hmac(const std::vector<uint8_t>& key, | ||
| absl::string_view string) { |
There was a problem hiding this comment.
Nit: string isn't very descriptive. Could you use message or data here?
source/common/ssl/utility.cc
Outdated
| } | ||
|
|
||
| std::vector<uint8_t> Utility::getSha256Hmac(const std::vector<uint8_t>& key, | ||
| absl::string_view string) { |
There was a problem hiding this comment.
Also, you can replace this whole function with:
std::vector<uint8_t> hmac(SHA256_DIGEST_LENGTH);
unsigned int len;
rc = HMAC(EVP_sha256(), key.data(), key.size(), message.data(), message.size(), hmac.data(), &len);
RELEASE_ASSERT(rc != nullptr, "Failed to create HMAC");
return hmac;
source/common/ssl/utility.cc
Outdated
| @@ -1,8 +1,12 @@ | |||
| #include "common/ssl/utility.h" | |||
There was a problem hiding this comment.
Note: This file doesn't exist anymore. @bdecoste moved TLS socket to extensions, so that it can be compiled out (and another TLS library can be used in it's place), it's new home is in source/extensions/transport_sockets/tls.
Having said that, I'd prefer if you created another target for this (e.g. source/extensions/common/crypto?), since those functions have nothing to do with SSL/TLS.
There was a problem hiding this comment.
I was actually thinking about making a "crypto" util but just ran with the suggestion to put it in common/ssl/utillty. Easy change :)
There was a problem hiding this comment.
I think this should be in core actually, since even core code might want to compute HMAC and SHAs. How about source/common/crypto?
There was a problem hiding this comment.
Right now I don't leverage any code that is different between Open/BoringSSL, but it would require some compile-time plumbing, which is not as clean as swapping in/out an extension, if neither lib is used or some other crypto impl was used instead.
How about I move this to source/common/crypto for now. And make an issue to propose adding a crypto impl extension api?
There was a problem hiding this comment.
Sure, that sounds good. Would like to merge this PR soon, so whatever is reasonable here and we can then look at the next one, thanks.
* Remove Logging::Id::aws * Move crypto utils to Extensions::Common::Crypto * Make request body signing optional * Move request canonicalization method to Aws::Utility * Explicity check for empty headers when canonicalizing Signed-off-by: Scott LaVigne <lavignes@amazon.com>
Signed-off-by: Scott LaVigne <lavignes@amazon.com>
| absl::string_view message) { | ||
| std::vector<uint8_t> hmac(SHA256_DIGEST_LENGTH); | ||
| unsigned int len; | ||
| auto ret = |
| parts.emplace_back(signing_headers); | ||
| parts.emplace_back(content_hash); | ||
| return absl::StrJoin(parts, "\n"); | ||
| } |
There was a problem hiding this comment.
It's kind of up to you largely, as long as you can provide adequate test coverage. The philosophy is at a bare minimum we have code coverage based on lines, but for complex/tricky code, it's often useful to ensure we can do behavioral/branch/functional coverage via unit tests. Some folks use testing peers, others code structure to do this. I personally don't like moving private to public methods in a class just for test, but I do like adding a logically separate utility class that has the methods as public where it makes sense.
htuch
left a comment
There was a problem hiding this comment.
LGTM modulo remaining comments, thanks!
Signed-off-by: Scott LaVigne <lavignes@amazon.com>
|
LGTM, @PiotrSikora WDYT? |
PiotrSikora
left a comment
There was a problem hiding this comment.
LGTM, sans the typo.
test/common/crypto/utility_test.cc
Outdated
| namespace Common { | ||
| namespace Crypto { | ||
|
|
||
| TEST(UtilityTest, TestSha256Disgest) { |
There was a problem hiding this comment.
Typo: s/Disgest/Digest/g (here and in the 2 tests below).
* github/master: (78 commits) transport capture: rename to tap (#5666) Misc additions for stubbing out QUICHE platform impls. (#5684) router: fix http/2 shadow request with body. (#5674) jwt_authn: cleanup, separate verifier from the matcher (#5689) redis: add latency stats for commands (#5593) config: invoke initialization callbacks on remote close (#5671) ext_authz: v2Alpha migration and documentation improvements (#5672) Update Datadog tracer version to v0.4.1 (#5691) envoy: basic spellcheck (#5688) fix a google string vs std::string issue for easier import (#5685) tools: tagging deprecation issues as no stalebot (#5680) health filter: cache degraded health checks (#5659) upstream: fix degraded health check and thread posting (#5662) tracing: inject tracing context in router (#5661) mysql_utils.cc: access EnvoyException by reference. (#5669) tap: introduce HTTP tap filter (#5515) fix a comment error (#5664) docs: add relase note for degraded health (#5653) Add yes flag for gcc-7 apt-repository (#5657) test: fix ssl_integration_test flake. (#5650) ... Signed-off-by: Scott LaVigne <lavignes@amazon.com>
Signed-off-by: Scott LaVigne <lavignes@amazon.com>
As part of leveraging Envoy in AWS App Mesh, we implemented a gRPC credentials extension that uses AWS IAM auth. To be able to use authenticate gRPC requests with AWS IAM credentials, we needed to add a code to Envoy to sign HTTP requests. More high-level details in envoyproxy#5215. I opened up a prior PR: envoyproxy#5546 which was admittedly too large and unwieldy. This is part 1 of a series of smaller patches needed to add our gRPC authentication extension to Envoy. Risk Level: Low Testing: Unit tests for this API. This has also been verified to work when communicating with App Mesh. Signed-off-by: Scott LaVigne <lavignes@amazon.com> Signed-off-by: Dan Zhang <danzh@google.com>
As part of leveraging Envoy in AWS App Mesh, we implemented a gRPC credentials extension that uses AWS IAM auth. To be able to use authenticate gRPC requests with AWS IAM credentials, we needed to add a code to Envoy to sign HTTP requests. More high-level details in envoyproxy#5215. I opened up a prior PR: envoyproxy#5546 which was admittedly too large and unwieldy. This is part 1 of a series of smaller patches needed to add our gRPC authentication extension to Envoy. Risk Level: Low Testing: Unit tests for this API. This has also been verified to work when communicating with App Mesh. Signed-off-by: Scott LaVigne <lavignes@amazon.com> Signed-off-by: Scott LaVigne <1406859+lavignes@users.noreply.github.com>
As part of leveraging Envoy in AWS App Mesh, we implemented a gRPC credentials extension that uses AWS IAM auth. To be able to use authenticate gRPC requests with AWS IAM credentials, we needed to add a code to Envoy to sign HTTP requests. More high-level details in envoyproxy#5215. I opened up a prior PR: envoyproxy#5546 which was admittedly too large and unwieldy. This is part 1 of a series of smaller patches needed to add our gRPC authentication extension to Envoy. Risk Level: Low Testing: Unit tests for this API. This has also been verified to work when communicating with App Mesh. Signed-off-by: Scott LaVigne <lavignes@amazon.com> Signed-off-by: Fred Douglas <fredlas@google.com>
Description: As part of leveraging Envoy in AWS App Mesh, we implemented a gRPC credentials extension that uses AWS IAM auth. To be able to use authenticate gRPC requests with AWS IAM credentials, we needed to add a code to Envoy to sign HTTP requests. More high-level details in #5215.
I opened up a prior PR: #5546 which was admittedly too large and unwieldy. This is part 1 of a series of smaller patches needed to add our gRPC authentication extension to Envoy.
Risk Level: Low
Testing: Unit tests for this API. This has also been verified to work when communicating with App Mesh.
Docs Changes: N/A
Release Notes: N/A