diff --git a/source/common/crypto/BUILD b/source/common/crypto/BUILD new file mode 100644 index 0000000000000..f802b7051e308 --- /dev/null +++ b/source/common/crypto/BUILD @@ -0,0 +1,23 @@ +licenses(["notice"]) # Apache 2 + +load( + "//bazel:envoy_build_system.bzl", + "envoy_cc_library", + "envoy_package", +) + +envoy_package() + +envoy_cc_library( + name = "utility_lib", + srcs = ["utility.cc"], + hdrs = ["utility.h"], + external_deps = [ + "ssl", + ], + deps = [ + "//include/envoy/buffer:buffer_interface", + "//source/common/common:assert_lib", + "//source/common/common:stack_array", + ], +) diff --git a/source/common/crypto/utility.cc b/source/common/crypto/utility.cc new file mode 100644 index 0000000000000..e5c5f80e72d40 --- /dev/null +++ b/source/common/crypto/utility.cc @@ -0,0 +1,45 @@ +#include "common/crypto/utility.h" + +#include "common/common/assert.h" +#include "common/common/stack_array.h" + +#include "openssl/evp.h" +#include "openssl/hmac.h" +#include "openssl/sha.h" + +namespace Envoy { +namespace Common { +namespace Crypto { + +std::vector Utility::getSha256Digest(const Buffer::Instance& buffer) { + std::vector digest(SHA256_DIGEST_LENGTH); + EVP_MD_CTX ctx; + auto rc = EVP_DigestInit(&ctx, EVP_sha256()); + RELEASE_ASSERT(rc == 1, "Failed to init digest context"); + const auto num_slices = buffer.getRawSlices(nullptr, 0); + STACK_ARRAY(slices, Buffer::RawSlice, num_slices); + buffer.getRawSlices(slices.begin(), num_slices); + for (const auto& slice : slices) { + rc = EVP_DigestUpdate(&ctx, slice.mem_, slice.len_); + RELEASE_ASSERT(rc == 1, "Failed to update digest"); + } + unsigned int len; + rc = EVP_DigestFinal(&ctx, digest.data(), &len); + RELEASE_ASSERT(rc == 1, "Failed to finalize digest"); + return digest; +} + +std::vector Utility::getSha256Hmac(const std::vector& key, + absl::string_view message) { + std::vector hmac(SHA256_DIGEST_LENGTH); + unsigned int len; + const auto ret = + HMAC(EVP_sha256(), key.data(), key.size(), reinterpret_cast(message.data()), + message.size(), hmac.data(), &len); + RELEASE_ASSERT(ret != nullptr, "Failed to create HMAC"); + return hmac; +} + +} // namespace Crypto +} // namespace Common +} // namespace Envoy diff --git a/source/common/crypto/utility.h b/source/common/crypto/utility.h new file mode 100644 index 0000000000000..d89c51fc75be0 --- /dev/null +++ b/source/common/crypto/utility.h @@ -0,0 +1,30 @@ +#pragma once + +#include "envoy/buffer/buffer.h" + +namespace Envoy { +namespace Common { +namespace Crypto { + +class Utility { +public: + /** + * Computes the SHA-256 digest of a buffer. + * @param buffer the buffer. + * @return a vector of bytes for the computed digest. + */ + static std::vector getSha256Digest(const Buffer::Instance& buffer); + + /** + * Computes the SHA-256 HMAC for a given key and message. + * @param key the HMAC function key. + * @param message message data for the HMAC function. + * @return a vector of bytes for the computed HMAC. + */ + static std::vector getSha256Hmac(const std::vector& key, + absl::string_view message); +}; + +} // namespace Crypto +} // namespace Common +} // namespace Envoy \ No newline at end of file diff --git a/source/extensions/filters/http/common/aws/BUILD b/source/extensions/filters/http/common/aws/BUILD new file mode 100644 index 0000000000000..e21ff2645279a --- /dev/null +++ b/source/extensions/filters/http/common/aws/BUILD @@ -0,0 +1,51 @@ +licenses(["notice"]) # Apache 2 + +load( + "//bazel:envoy_build_system.bzl", + "envoy_cc_library", + "envoy_package", +) + +envoy_package() + +envoy_cc_library( + name = "signer_interface", + hdrs = ["signer.h"], + deps = [ + "//include/envoy/http:message_interface", + ], +) + +envoy_cc_library( + name = "signer_impl_lib", + srcs = ["signer_impl.cc"], + hdrs = ["signer_impl.h"], + deps = [ + ":credentials_provider_interface", + ":signer_interface", + ":utility_lib", + "//source/common/buffer:buffer_lib", + "//source/common/common:hex_lib", + "//source/common/common:logger_lib", + "//source/common/common:utility_lib", + "//source/common/crypto:utility_lib", + "//source/common/http:headers_lib", + "//source/common/singleton:const_singleton", + ], +) + +envoy_cc_library( + name = "credentials_provider_interface", + hdrs = ["credentials_provider.h"], + external_deps = ["abseil_optional"], +) + +envoy_cc_library( + name = "utility_lib", + srcs = ["utility.cc"], + hdrs = ["utility.h"], + deps = [ + "//source/common/common:utility_lib", + "//source/common/http:headers_lib", + ], +) diff --git a/source/extensions/filters/http/common/aws/credentials_provider.h b/source/extensions/filters/http/common/aws/credentials_provider.h new file mode 100644 index 0000000000000..1afc551f0bf3c --- /dev/null +++ b/source/extensions/filters/http/common/aws/credentials_provider.h @@ -0,0 +1,53 @@ +#pragma once + +#include + +#include "envoy/common/pure.h" + +#include "absl/strings/string_view.h" +#include "absl/types/optional.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Common { +namespace Aws { + +class Credentials { +public: + Credentials() = default; + + Credentials(absl::string_view access_key_id, absl::string_view secret_access_key) + : access_key_id_(access_key_id), secret_access_key_(secret_access_key) {} + + Credentials(absl::string_view access_key_id, absl::string_view secret_access_key, + absl::string_view session_token) + : access_key_id_(access_key_id), secret_access_key_(secret_access_key), + session_token_(session_token) {} + + const absl::optional& accessKeyId() const { return access_key_id_; } + + const absl::optional& secretAccessKey() const { return secret_access_key_; } + + const absl::optional& sessionToken() const { return session_token_; } + +private: + absl::optional access_key_id_; + absl::optional secret_access_key_; + absl::optional session_token_; +}; + +class CredentialsProvider { +public: + virtual ~CredentialsProvider() = default; + + virtual Credentials getCredentials() PURE; +}; + +typedef std::shared_ptr CredentialsProviderSharedPtr; + +} // namespace Aws +} // namespace Common +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy \ No newline at end of file diff --git a/source/extensions/filters/http/common/aws/signer.h b/source/extensions/filters/http/common/aws/signer.h new file mode 100644 index 0000000000000..bf504bc4356b1 --- /dev/null +++ b/source/extensions/filters/http/common/aws/signer.h @@ -0,0 +1,32 @@ +#pragma once + +#include "envoy/common/pure.h" +#include "envoy/http/message.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Common { +namespace Aws { + +// TODO(lavignes): Move this interface to include/envoy if this is needed elsewhere +class Signer { +public: + virtual ~Signer() = default; + + /** + * Sign an AWS request. + * @param message an AWS API request message. + * @param sign_body include the message body in the signature. The body must be fully buffered. + * @throws EnvoyException if the request cannot be signed. + */ + virtual void sign(Http::Message& message, bool sign_body) PURE; +}; + +typedef std::unique_ptr SignerPtr; + +} // namespace Aws +} // namespace Common +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy \ No newline at end of file diff --git a/source/extensions/filters/http/common/aws/signer_impl.cc b/source/extensions/filters/http/common/aws/signer_impl.cc new file mode 100644 index 0000000000000..432396c85ce87 --- /dev/null +++ b/source/extensions/filters/http/common/aws/signer_impl.cc @@ -0,0 +1,116 @@ +#include "extensions/filters/http/common/aws/signer_impl.h" + +#include "envoy/common/exception.h" + +#include "common/buffer/buffer_impl.h" +#include "common/common/fmt.h" +#include "common/common/hex.h" +#include "common/crypto/utility.h" +#include "common/http/headers.h" + +#include "extensions/filters/http/common/aws/utility.h" + +#include "absl/strings/str_join.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Common { +namespace Aws { + +void SignerImpl::sign(Http::Message& message, bool sign_body) { + const auto& credentials = credentials_provider_->getCredentials(); + if (!credentials.accessKeyId() || !credentials.secretAccessKey()) { + // Empty or "anonymous" credentials are a valid use-case for non-production environments. + // This behavior matches what the AWS SDK would do. + return; + } + auto& headers = message.headers(); + const auto* method_header = headers.Method(); + if (method_header == nullptr) { + throw EnvoyException("Message is missing :method header"); + } + const auto* path_header = headers.Path(); + if (path_header == nullptr) { + throw EnvoyException("Message is missing :path header"); + } + if (credentials.sessionToken()) { + headers.addCopy(SignatureHeaders::get().SecurityToken, credentials.sessionToken().value()); + } + const auto long_date = long_date_formatter_.now(time_source_); + const auto short_date = short_date_formatter_.now(time_source_); + headers.addCopy(SignatureHeaders::get().Date, long_date); + const auto content_hash = createContentHash(message, sign_body); + // Phase 1: Create a canonical request + const auto canonical_headers = Utility::canonicalizeHeaders(headers); + const auto canonical_request = Utility::createCanonicalRequest( + method_header->value().getStringView(), path_header->value().getStringView(), + canonical_headers, content_hash); + ENVOY_LOG(debug, "Canonical request:\n{}", canonical_request); + // Phase 2: Create a string to sign + const auto credential_scope = createCredentialScope(short_date); + const auto string_to_sign = createStringToSign(canonical_request, long_date, credential_scope); + ENVOY_LOG(debug, "String to sign:\n{}", string_to_sign); + // Phase 3: Create a signature + const auto signature = + createSignature(credentials.secretAccessKey().value(), short_date, string_to_sign); + // Phase 4: Sign request + const auto authorization_header = createAuthorizationHeader( + credentials.accessKeyId().value(), credential_scope, canonical_headers, signature); + ENVOY_LOG(debug, "Signing request with: {}", authorization_header); + headers.addCopy(Http::Headers::get().Authorization, authorization_header); +} + +std::string SignerImpl::createContentHash(Http::Message& message, bool sign_body) const { + if (!sign_body) { + return SignatureConstants::get().HashedEmptyString; + } + const auto content_hash = + message.body() ? Hex::encode(Envoy::Common::Crypto::Utility::getSha256Digest(*message.body())) + : SignatureConstants::get().HashedEmptyString; + message.headers().addCopy(SignatureHeaders::get().ContentSha256, content_hash); + return content_hash; +} + +std::string SignerImpl::createCredentialScope(absl::string_view short_date) const { + return fmt::format(SignatureConstants::get().CredentialScopeFormat, short_date, region_, + service_name_); +} + +std::string SignerImpl::createStringToSign(absl::string_view canonical_request, + absl::string_view long_date, + absl::string_view credential_scope) const { + return fmt::format(SignatureConstants::get().StringToSignFormat, long_date, credential_scope, + Hex::encode(Envoy::Common::Crypto::Utility::getSha256Digest( + Buffer::OwnedImpl(canonical_request)))); +} + +std::string SignerImpl::createSignature(absl::string_view secret_access_key, + absl::string_view short_date, + absl::string_view string_to_sign) const { + const auto secret_key = + absl::StrCat(SignatureConstants::get().SignatureVersion, secret_access_key); + const auto date_key = Envoy::Common::Crypto::Utility::getSha256Hmac( + std::vector(secret_key.begin(), secret_key.end()), short_date); + const auto region_key = Envoy::Common::Crypto::Utility::getSha256Hmac(date_key, region_); + const auto service_key = Envoy::Common::Crypto::Utility::getSha256Hmac(region_key, service_name_); + const auto signing_key = Envoy::Common::Crypto::Utility::getSha256Hmac( + service_key, SignatureConstants::get().Aws4Request); + return Hex::encode(Envoy::Common::Crypto::Utility::getSha256Hmac(signing_key, string_to_sign)); +} + +std::string +SignerImpl::createAuthorizationHeader(absl::string_view access_key_id, + absl::string_view credential_scope, + const std::map& canonical_headers, + absl::string_view signature) const { + const auto signed_headers = Utility::joinCanonicalHeaderNames(canonical_headers); + return fmt::format(SignatureConstants::get().AuthorizationHeaderFormat, access_key_id, + credential_scope, signed_headers, signature); +} + +} // namespace Aws +} // namespace Common +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy \ No newline at end of file diff --git a/source/extensions/filters/http/common/aws/signer_impl.h b/source/extensions/filters/http/common/aws/signer_impl.h new file mode 100644 index 0000000000000..8a7dabfdcf765 --- /dev/null +++ b/source/extensions/filters/http/common/aws/signer_impl.h @@ -0,0 +1,84 @@ +#pragma once + +#include "common/common/logger.h" +#include "common/common/utility.h" +#include "common/singleton/const_singleton.h" + +#include "extensions/filters/http/common/aws/credentials_provider.h" +#include "extensions/filters/http/common/aws/signer.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Common { +namespace Aws { + +class SignatureHeaderValues { +public: + const Http::LowerCaseString ContentSha256{"x-amz-content-sha256"}; + const Http::LowerCaseString Date{"x-amz-date"}; + const Http::LowerCaseString SecurityToken{"x-amz-security-token"}; +}; + +typedef ConstSingleton SignatureHeaders; + +class SignatureConstantValues { +public: + const std::string Aws4Request{"aws4_request"}; + const std::string AuthorizationHeaderFormat{ + "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}"}; + const std::string CredentialScopeFormat{"{}/{}/{}/aws4_request"}; + const std::string HashedEmptyString{ + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}; + const std::string SignatureVersion{"AWS4"}; + const std::string StringToSignFormat{"AWS4-HMAC-SHA256\n{}\n{}\n{}"}; + + const std::string LongDateFormat{"%Y%m%dT%H%M00Z"}; + const std::string ShortDateFormat{"%Y%m%d"}; +}; + +typedef ConstSingleton SignatureConstants; + +/** + * Implementation of the Signature V4 signing process. + * See https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html + */ +class SignerImpl : public Signer, public Logger::Loggable { +public: + SignerImpl(absl::string_view service_name, absl::string_view region, + const CredentialsProviderSharedPtr& credentials_provider, TimeSource& time_source) + : service_name_(service_name), region_(region), credentials_provider_(credentials_provider), + time_source_(time_source), long_date_formatter_(SignatureConstants::get().LongDateFormat), + short_date_formatter_(SignatureConstants::get().ShortDateFormat) {} + + void sign(Http::Message& message, bool sign_body = false) override; + +private: + std::string createContentHash(Http::Message& message, bool sign_body) const; + + std::string createCredentialScope(absl::string_view short_date) const; + + std::string createStringToSign(absl::string_view canonical_request, absl::string_view long_date, + absl::string_view credential_scope) const; + + std::string createSignature(absl::string_view secret_access_key, absl::string_view short_date, + absl::string_view string_to_sign) const; + + std::string createAuthorizationHeader(absl::string_view access_key_id, + absl::string_view credential_scope, + const std::map& canonical_headers, + absl::string_view signature) const; + + const std::string service_name_; + const std::string region_; + CredentialsProviderSharedPtr credentials_provider_; + TimeSource& time_source_; + DateFormatter long_date_formatter_; + DateFormatter short_date_formatter_; +}; + +} // namespace Aws +} // namespace Common +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy \ No newline at end of file diff --git a/source/extensions/filters/http/common/aws/utility.cc b/source/extensions/filters/http/common/aws/utility.cc new file mode 100644 index 0000000000000..55f4a5aab08b4 --- /dev/null +++ b/source/extensions/filters/http/common/aws/utility.cc @@ -0,0 +1,94 @@ +#include "extensions/filters/http/common/aws/utility.h" + +#include "common/common/fmt.h" +#include "common/common/utility.h" + +#include "absl/strings/str_join.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Common { +namespace Aws { + +std::map Utility::canonicalizeHeaders(const Http::HeaderMap& headers) { + std::map out; + headers.iterate( + [](const Http::HeaderEntry& entry, void* context) -> Http::HeaderMap::Iterate { + auto* map = static_cast*>(context); + // Skip empty headers + if (entry.key().empty() || entry.value().empty()) { + return Http::HeaderMap::Iterate::Continue; + } + // Pseudo-headers should not be canonicalized + if (entry.key().c_str()[0] == ':') { + return Http::HeaderMap::Iterate::Continue; + } + std::string value(entry.value().getStringView()); + // Remove leading, trailing, and deduplicate repeated ascii spaces + absl::RemoveExtraAsciiWhitespace(&value); + const auto iter = map->find(entry.key().c_str()); + // If the entry already exists, append the new value to the end + if (iter != map->end()) { + iter->second += fmt::format(",{}", value); + } else { + map->emplace(entry.key().c_str(), value); + } + return Http::HeaderMap::Iterate::Continue; + }, + &out); + // The AWS SDK has a quirk where it removes "default ports" (80, 443) from the host headers + // Additionally, we canonicalize the :authority header as "host" + // TODO(lavignes): This may need to be tweaked to canonicalize :authority for HTTP/2 requests + const auto* authority_header = headers.Host(); + if (authority_header != nullptr && !authority_header->value().empty()) { + const auto& value = authority_header->value().getStringView(); + const auto parts = StringUtil::splitToken(value, ":"); + if (parts.size() > 1 && (parts[1] == "80" || parts[1] == "443")) { + // Has default port, so use only the host part + out.emplace(Http::Headers::get().HostLegacy.get(), std::string(parts[0])); + } else { + out.emplace(Http::Headers::get().HostLegacy.get(), std::string(value)); + } + } + return out; +} + +std::string +Utility::createCanonicalRequest(absl::string_view method, absl::string_view path, + const std::map& canonical_headers, + absl::string_view content_hash) { + std::vector parts; + parts.emplace_back(method); + // don't include the query part of the path + const auto path_part = StringUtil::cropRight(path, "?"); + parts.emplace_back(path_part.empty() ? "/" : path_part); + const auto query_part = StringUtil::cropLeft(path, "?"); + // if query_part == path_part, then there is no query + parts.emplace_back(query_part == path_part ? "" : query_part); + std::vector formatted_headers; + formatted_headers.reserve(canonical_headers.size()); + for (const auto& header : canonical_headers) { + formatted_headers.emplace_back(fmt::format("{}:{}", header.first, header.second)); + parts.emplace_back(formatted_headers.back()); + } + // need an extra blank space after the canonical headers + parts.emplace_back(""); + const auto signed_headers = Utility::joinCanonicalHeaderNames(canonical_headers); + parts.emplace_back(signed_headers); + parts.emplace_back(content_hash); + return absl::StrJoin(parts, "\n"); +} + +std::string +Utility::joinCanonicalHeaderNames(const std::map& canonical_headers) { + return absl::StrJoin(canonical_headers, ";", [](auto* out, const auto& pair) { + return absl::StrAppend(out, pair.first); + }); +} + +} // namespace Aws +} // namespace Common +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy \ No newline at end of file diff --git a/source/extensions/filters/http/common/aws/utility.h b/source/extensions/filters/http/common/aws/utility.h new file mode 100644 index 0000000000000..ead9339fb826b --- /dev/null +++ b/source/extensions/filters/http/common/aws/utility.h @@ -0,0 +1,48 @@ +#pragma once + +#include "common/http/headers.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Common { +namespace Aws { + +class Utility { +public: + /** + * Creates a canonicalized header map used in creating a AWS Signature V4 canonical request. + * See https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html + * @param headers a header map to canonicalize. + * @return a std::map of canonicalized headers to be used in building a canonical request. + */ + static std::map canonicalizeHeaders(const Http::HeaderMap& headers); + + /** + * Creates an AWS Signature V4 canonical request string. + * See https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html + * @param method the HTTP request method. + * @param path the request path. + * @param canonical_headers the pre-canonicalized request headers. + * @param content_hash the hashed request body. + * @return the canonicalized request string. + */ + static std::string + createCanonicalRequest(absl::string_view method, absl::string_view path, + const std::map& canonical_headers, + absl::string_view content_hash); + + /** + * Get the semicolon-delimited string of canonical header names. + * @param canonical_headers the pre-canonicalized request headers. + * @return the header names as a semicolon-delimited string. + */ + static std::string + joinCanonicalHeaderNames(const std::map& canonical_headers); +}; + +} // namespace Aws +} // namespace Common +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy \ No newline at end of file diff --git a/test/common/crypto/BUILD b/test/common/crypto/BUILD new file mode 100644 index 0000000000000..6cf18be53a67a --- /dev/null +++ b/test/common/crypto/BUILD @@ -0,0 +1,22 @@ +licenses(["notice"]) # Apache 2 + +load( + "//bazel:envoy_build_system.bzl", + "envoy_cc_test", + "envoy_package", +) + +envoy_package() + +envoy_cc_test( + name = "utility_test", + srcs = [ + "utility_test.cc", + ], + external_deps = ["ssl"], + deps = [ + "//source/common/buffer:buffer_lib", + "//source/common/common:hex_lib", + "//source/common/crypto:utility_lib", + ], +) diff --git a/test/common/crypto/utility_test.cc b/test/common/crypto/utility_test.cc new file mode 100644 index 0000000000000..46c6852102441 --- /dev/null +++ b/test/common/crypto/utility_test.cc @@ -0,0 +1,54 @@ +#include "common/buffer/buffer_impl.h" +#include "common/common/hex.h" +#include "common/crypto/utility.h" + +#include "gtest/gtest.h" + +namespace Envoy { +namespace Common { +namespace Crypto { + +TEST(UtilityTest, TestSha256Digest) { + const Buffer::OwnedImpl buffer("test data"); + const auto digest = Utility::getSha256Digest(buffer); + EXPECT_EQ("916f0027a575074ce72a331777c3478d6513f786a591bd892da1a577bf2335f9", + Hex::encode(digest)); +} + +TEST(UtilityTest, TestSha256DigestWithEmptyBuffer) { + const Buffer::OwnedImpl buffer; + const auto digest = Utility::getSha256Digest(buffer); + EXPECT_EQ("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + Hex::encode(digest)); +} + +TEST(UtilityTest, TestSha256DigestGrowingBuffer) { + // Adding multiple slices to the buffer + Buffer::OwnedImpl buffer("slice 1"); + auto digest = Utility::getSha256Digest(buffer); + EXPECT_EQ("76571770bb46bdf51e1aba95b23c681fda27f6ae56a8a90898a4cb7556e19dcb", + Hex::encode(digest)); + buffer.add("slice 2"); + digest = Utility::getSha256Digest(buffer); + EXPECT_EQ("290b462b0fe5edcf6b8532de3ca70da8ab77937212042bb959192ec6c9f95b9a", + Hex::encode(digest)); + buffer.add("slice 3"); + digest = Utility::getSha256Digest(buffer); + EXPECT_EQ("29606bbf02fdc40007cdf799de36d931e3587dafc086937efd6599a4ea9397aa", + Hex::encode(digest)); +} + +TEST(UtilityTest, TestSha256Hmac) { + const std::string key = "key"; + auto hmac = Utility::getSha256Hmac(std::vector(key.begin(), key.end()), "test data"); + EXPECT_EQ("087d9eb992628854842ca4dbf790f8164c80355c1e78b72789d830334927a84c", Hex::encode(hmac)); +} + +TEST(UtilityTest, TestSha256HmacWithEmptyArguments) { + auto hmac = Utility::getSha256Hmac(std::vector(), ""); + EXPECT_EQ("b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad", Hex::encode(hmac)); +} + +} // namespace Crypto +} // namespace Common +} // namespace Envoy diff --git a/test/extensions/filters/http/common/aws/BUILD b/test/extensions/filters/http/common/aws/BUILD new file mode 100644 index 0000000000000..1c5c0541e4ebf --- /dev/null +++ b/test/extensions/filters/http/common/aws/BUILD @@ -0,0 +1,42 @@ +licenses(["notice"]) # Apache 2 + +load( + "//bazel:envoy_build_system.bzl", + "envoy_cc_mock", + "envoy_cc_test", + "envoy_package", +) + +envoy_package() + +envoy_cc_mock( + name = "aws_mocks", + srcs = ["mocks.cc"], + hdrs = ["mocks.h"], + deps = [ + "//source/extensions/filters/http/common/aws:credentials_provider_interface", + "//source/extensions/filters/http/common/aws:signer_interface", + ], +) + +envoy_cc_test( + name = "signer_impl_test", + srcs = ["signer_impl_test.cc"], + deps = [ + "//source/common/buffer:buffer_lib", + "//source/common/http:message_lib", + "//source/extensions/filters/http/common/aws:signer_impl_lib", + "//test/extensions/filters/http/common/aws:aws_mocks", + "//test/test_common:simulated_time_system_lib", + "//test/test_common:utility_lib", + ], +) + +envoy_cc_test( + name = "utility_test", + srcs = ["utility_test.cc"], + deps = [ + "//source/extensions/filters/http/common/aws:utility_lib", + "//test/test_common:utility_lib", + ], +) diff --git a/test/extensions/filters/http/common/aws/mocks.cc b/test/extensions/filters/http/common/aws/mocks.cc new file mode 100644 index 0000000000000..cdc43b1ed1c30 --- /dev/null +++ b/test/extensions/filters/http/common/aws/mocks.cc @@ -0,0 +1,21 @@ +#include "test/extensions/filters/http/common/aws/mocks.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Common { +namespace Aws { + +MockCredentialsProvider::MockCredentialsProvider() {} + +MockCredentialsProvider::~MockCredentialsProvider() {} + +MockSigner::MockSigner() {} + +MockSigner::~MockSigner() {} + +} // namespace Aws +} // namespace Common +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy \ No newline at end of file diff --git a/test/extensions/filters/http/common/aws/mocks.h b/test/extensions/filters/http/common/aws/mocks.h new file mode 100644 index 0000000000000..857ed433ac2ba --- /dev/null +++ b/test/extensions/filters/http/common/aws/mocks.h @@ -0,0 +1,34 @@ +#pragma once + +#include "extensions/filters/http/common/aws/credentials_provider.h" +#include "extensions/filters/http/common/aws/signer.h" + +#include "gmock/gmock.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Common { +namespace Aws { + +class MockCredentialsProvider : public CredentialsProvider { +public: + MockCredentialsProvider(); + ~MockCredentialsProvider(); + + MOCK_METHOD0(getCredentials, Credentials()); +}; + +class MockSigner : public Signer { +public: + MockSigner(); + ~MockSigner(); + + MOCK_METHOD2(sign, void(Http::Message&, bool)); +}; + +} // namespace Aws +} // namespace Common +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/common/aws/signer_impl_test.cc b/test/extensions/filters/http/common/aws/signer_impl_test.cc new file mode 100644 index 0000000000000..7d8d7331bc3d5 --- /dev/null +++ b/test/extensions/filters/http/common/aws/signer_impl_test.cc @@ -0,0 +1,167 @@ +#include "common/buffer/buffer_impl.h" +#include "common/http/message_impl.h" + +#include "extensions/filters/http/common/aws/signer_impl.h" +#include "extensions/filters/http/common/aws/utility.h" + +#include "test/extensions/filters/http/common/aws/mocks.h" +#include "test/test_common/simulated_time_system.h" +#include "test/test_common/utility.h" + +using testing::NiceMock; +using testing::Return; + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Common { +namespace Aws { + +class SignerImplTest : public testing::Test { +public: + SignerImplTest() + : credentials_provider_(new NiceMock()), + message_(new Http::RequestMessageImpl()), + signer_("service", "region", CredentialsProviderSharedPtr{credentials_provider_}, + time_system_), + credentials_("akid", "secret"), token_credentials_("akid", "secret", "token") { + // 20180102T030405Z + time_system_.setSystemTime(std::chrono::milliseconds(1514862245000)); + } + + void addMethod(const std::string& method) { message_->headers().insertMethod().value(method); } + + void addPath(const std::string& path) { message_->headers().insertPath().value(path); } + + void addHeader(const std::string& key, const std::string& value) { + message_->headers().addCopy(Http::LowerCaseString(key), value); + } + + void setBody(const std::string& body) { + message_->body() = std::make_unique(body); + } + + NiceMock* credentials_provider_; + Event::SimulatedTimeSystem time_system_; + Http::MessagePtr message_; + SignerImpl signer_; + Credentials credentials_; + Credentials token_credentials_; + absl::optional region_; +}; + +// No authorization header should be present when the credentials are empty +TEST_F(SignerImplTest, AnonymousCredentials) { + EXPECT_CALL(*credentials_provider_, getCredentials()).WillOnce(Return(Credentials())); + signer_.sign(*message_); + EXPECT_EQ(nullptr, message_->headers().Authorization()); +} + +// HTTP :method header is required +TEST_F(SignerImplTest, MissingMethodException) { + EXPECT_CALL(*credentials_provider_, getCredentials()).WillOnce(Return(credentials_)); + EXPECT_THROW_WITH_MESSAGE(signer_.sign(*message_), EnvoyException, + "Message is missing :method header"); + EXPECT_EQ(nullptr, message_->headers().Authorization()); +} + +// HTTP :path header is required +TEST_F(SignerImplTest, MissingPathException) { + EXPECT_CALL(*credentials_provider_, getCredentials()).WillOnce(Return(credentials_)); + addMethod("GET"); + EXPECT_THROW_WITH_MESSAGE(signer_.sign(*message_), EnvoyException, + "Message is missing :path header"); + EXPECT_EQ(nullptr, message_->headers().Authorization()); +} + +// Verify we sign the date header +TEST_F(SignerImplTest, SignDateHeader) { + EXPECT_CALL(*credentials_provider_, getCredentials()).WillOnce(Return(credentials_)); + addMethod("GET"); + addPath("/"); + signer_.sign(*message_); + EXPECT_EQ(nullptr, message_->headers().get(SignatureHeaders::get().ContentSha256)); + EXPECT_STREQ("20180102T030400Z", + message_->headers().get(SignatureHeaders::get().Date)->value().c_str()); + EXPECT_STREQ("AWS4-HMAC-SHA256 Credential=akid/20180102/region/service/aws4_request, " + "SignedHeaders=x-amz-date, " + "Signature=1310784f67248cab70d98b9404d601f30d8fe20bd1820560cce224f4131dc1cc", + message_->headers().Authorization()->value().c_str()); +} + +// Verify we sign the security token header if the token is present in the credentials +TEST_F(SignerImplTest, SignSecurityTokenHeader) { + EXPECT_CALL(*credentials_provider_, getCredentials()).WillOnce(Return(token_credentials_)); + addMethod("GET"); + addPath("/"); + signer_.sign(*message_); + EXPECT_STREQ("token", + message_->headers().get(SignatureHeaders::get().SecurityToken)->value().c_str()); + EXPECT_STREQ("AWS4-HMAC-SHA256 Credential=akid/20180102/region/service/aws4_request, " + "SignedHeaders=x-amz-date;x-amz-security-token, " + "Signature=ff1d9fa7e54a72677b5336df047bb1f1493f86b92099973bf62da3af852d1679", + message_->headers().Authorization()->value().c_str()); +} + +// Verify we sign the content header as the hashed empty string if the body is empty +TEST_F(SignerImplTest, SignEmptyContentHeader) { + EXPECT_CALL(*credentials_provider_, getCredentials()).WillOnce(Return(credentials_)); + addMethod("GET"); + addPath("/"); + signer_.sign(*message_, true); + EXPECT_STREQ(SignatureConstants::get().HashedEmptyString.c_str(), + message_->headers().get(SignatureHeaders::get().ContentSha256)->value().c_str()); + EXPECT_STREQ("AWS4-HMAC-SHA256 Credential=akid/20180102/region/service/aws4_request, " + "SignedHeaders=x-amz-content-sha256;x-amz-date, " + "Signature=4ee6aa9355259c18133f150b139ea9aeb7969c9408ad361b2151f50a516afe42", + message_->headers().Authorization()->value().c_str()); +} + +// Verify we sign the content header correctly when we have a body +TEST_F(SignerImplTest, SignContentHeader) { + EXPECT_CALL(*credentials_provider_, getCredentials()).WillOnce(Return(credentials_)); + addMethod("POST"); + addPath("/"); + setBody("test1234"); + signer_.sign(*message_, true); + EXPECT_STREQ("937e8d5fbb48bd4949536cd65b8d35c426b80d2f830c5c308e2cdec422ae2244", + message_->headers().get(SignatureHeaders::get().ContentSha256)->value().c_str()); + EXPECT_STREQ("AWS4-HMAC-SHA256 Credential=akid/20180102/region/service/aws4_request, " + "SignedHeaders=x-amz-content-sha256;x-amz-date, " + "Signature=4eab89c36f45f2032d6010ba1adab93f8510ddd6afe540821f3a05bb0253e27b", + message_->headers().Authorization()->value().c_str()); +} + +// Verify we sign some extra headers +TEST_F(SignerImplTest, SignExtraHeaders) { + EXPECT_CALL(*credentials_provider_, getCredentials()).WillOnce(Return(credentials_)); + addMethod("GET"); + addPath("/"); + addHeader("a", "a_value"); + addHeader("b", "b_value"); + addHeader("c", "c_value"); + signer_.sign(*message_); + EXPECT_STREQ("AWS4-HMAC-SHA256 Credential=akid/20180102/region/service/aws4_request, " + "SignedHeaders=a;b;c;x-amz-date, " + "Signature=d5e025e1cf0d5af0d83110bc2ef1cafd2d9dca1dea9d7767f58308da64aa6558", + message_->headers().Authorization()->value().c_str()); +} + +// Verify signing a host header +TEST_F(SignerImplTest, SignHostHeader) { + EXPECT_CALL(*credentials_provider_, getCredentials()).WillOnce(Return(credentials_)); + addMethod("GET"); + addPath("/"); + addHeader("host", "www.example.com"); + signer_.sign(*message_); + EXPECT_STREQ("AWS4-HMAC-SHA256 Credential=akid/20180102/region/service/aws4_request, " + "SignedHeaders=host;x-amz-date, " + "Signature=60216ee44dd651322ea10cc6747308dd30e582aaa773f6c1b1354e486385c021", + message_->headers().Authorization()->value().c_str()); +} + +} // namespace Aws +} // namespace Common +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy \ No newline at end of file diff --git a/test/extensions/filters/http/common/aws/utility_test.cc b/test/extensions/filters/http/common/aws/utility_test.cc new file mode 100644 index 0000000000000..802e06036d3f5 --- /dev/null +++ b/test/extensions/filters/http/common/aws/utility_test.cc @@ -0,0 +1,153 @@ +#include "extensions/filters/http/common/aws/utility.h" + +#include "test/test_common/utility.h" + +#include "gtest/gtest.h" + +using testing::ElementsAre; +using testing::Pair; + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Common { +namespace Aws { + +// Headers must be in alphabetical order by virtue of std::map +TEST(UtilityTest, CanonicalizeHeadersInAlphabeticalOrder) { + Http::TestHeaderMapImpl headers{ + {"d", "d_value"}, {"f", "f_value"}, {"b", "b_value"}, + {"e", "e_value"}, {"c", "c_value"}, {"a", "a_value"}, + }; + const auto map = Utility::canonicalizeHeaders(headers); + EXPECT_THAT(map, ElementsAre(Pair("a", "a_value"), Pair("b", "b_value"), Pair("c", "c_value"), + Pair("d", "d_value"), Pair("e", "e_value"), Pair("f", "f_value"))); +} + +// HTTP pseudo-headers should be ignored +TEST(UtilityTest, CanonicalizeHeadersSkippingPseudoHeaders) { + Http::TestHeaderMapImpl headers{ + {":path", "path_value"}, + {":method", "GET"}, + {"normal", "normal_value"}, + }; + const auto map = Utility::canonicalizeHeaders(headers); + EXPECT_THAT(map, ElementsAre(Pair("normal", "normal_value"))); +} + +// Repeated headers are joined with commas +TEST(UtilityTest, CanonicalizeHeadersJoiningDuplicatesWithCommas) { + Http::TestHeaderMapImpl headers{ + {"a", "a_value1"}, + {"a", "a_value2"}, + {"a", "a_value3"}, + }; + const auto map = Utility::canonicalizeHeaders(headers); + EXPECT_THAT(map, ElementsAre(Pair("a", "a_value1,a_value2,a_value3"))); +} + +// We canonicalize the :authority header as host +TEST(UtilityTest, CanonicalizeHeadersAuthorityToHost) { + Http::TestHeaderMapImpl headers{ + {":authority", "authority_value"}, + }; + const auto map = Utility::canonicalizeHeaders(headers); + EXPECT_THAT(map, ElementsAre(Pair("host", "authority_value"))); +} + +// Ports 80 and 443 are omitted from the host headers +TEST(UtilityTest, CanonicalizeHeadersRemovingDefaultPortsFromHost) { + Http::TestHeaderMapImpl headers_port80{ + {":authority", "example.com:80"}, + }; + const auto map_port80 = Utility::canonicalizeHeaders(headers_port80); + EXPECT_THAT(map_port80, ElementsAre(Pair("host", "example.com"))); + + Http::TestHeaderMapImpl headers_port443{ + {":authority", "example.com:443"}, + }; + const auto map_port443 = Utility::canonicalizeHeaders(headers_port443); + EXPECT_THAT(map_port443, ElementsAre(Pair("host", "example.com"))); +} + +// Whitespace is trimmed from headers +TEST(UtilityTest, CanonicalizeHeadersTrimmingWhitespace) { + Http::TestHeaderMapImpl headers{ + {"leading", " leading value"}, + {"trailing", "trailing value "}, + {"internal", "internal value"}, + {"all", " all value "}, + }; + const auto map = Utility::canonicalizeHeaders(headers); + EXPECT_THAT(map, + ElementsAre(Pair("all", "all value"), Pair("internal", "internal value"), + Pair("leading", "leading value"), Pair("trailing", "trailing value"))); +} + +// Verify the format of a minimalist canonical request +TEST(UtilityTest, MinimalCanonicalRequest) { + std::map headers; + const auto request = Utility::createCanonicalRequest("GET", "", headers, "content-hash"); + EXPECT_EQ(R"(GET +/ + + + +content-hash)", + request); +} + +TEST(UtilityTest, CanonicalRequestWithQueryString) { + const std::map headers; + const auto request = Utility::createCanonicalRequest("GET", "?query", headers, "content-hash"); + EXPECT_EQ(R"(GET +/ +query + + +content-hash)", + request); +} + +TEST(UtilityTest, CanonicalRequestWithHeaders) { + const std::map headers = { + {"header1", "value1"}, + {"header2", "value2"}, + {"header3", "value3"}, + }; + const auto request = Utility::createCanonicalRequest("GET", "", headers, "content-hash"); + EXPECT_EQ(R"(GET +/ + +header1:value1 +header2:value2 +header3:value3 + +header1;header2;header3 +content-hash)", + request); +} + +// Verify headers are joined with ";" +TEST(UtilityTest, JoinCanonicalHeaderNames) { + std::map headers = { + {"header1", "value1"}, + {"header2", "value2"}, + {"header3", "value3"}, + }; + const auto names = Utility::joinCanonicalHeaderNames(headers); + EXPECT_EQ("header1;header2;header3", names); +} + +// Verify we return "" when there are no headers +TEST(UtilityTest, JoinCanonicalHeaderNamesWithEmptyMap) { + std::map headers; + const auto names = Utility::joinCanonicalHeaderNames(headers); + EXPECT_EQ("", names); +} + +} // namespace Aws +} // namespace Common +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy \ No newline at end of file