diff --git a/services-custom/sns-message-manager/pom.xml b/services-custom/sns-message-manager/pom.xml
new file mode 100644
index 000000000000..bff6e9291e16
--- /dev/null
+++ b/services-custom/sns-message-manager/pom.xml
@@ -0,0 +1,140 @@
+
+
+
+
+ 4.0.0
+
+ software.amazon.awssdk
+ aws-sdk-java-pom
+ 2.42.35-SNAPSHOT
+ ../../pom.xml
+
+ sns-message-manager
+ AWS Java SDK :: SNS :: Message Manager
+
+ The AWS SDK for Java - SNS Message Manager is a library for deserializing and verifying messages received from SNS.
+
+ https://aws.amazon.com/sdkforjava
+
+ 1.8
+ ${project.parent.version}
+
+
+
+
+
+ software.amazon.awssdk
+ bom-internal
+ ${awsjavasdk.version}
+ pom
+ import
+
+
+
+
+
+
+ software.amazon.awssdk
+ utils
+ ${project.version}
+
+
+ software.amazon.awssdk
+ json-utils
+ ${project.version}
+
+
+ software.amazon.awssdk
+ annotations
+ ${project.version}
+
+
+ software.amazon.awssdk
+ sdk-core
+ ${project.version}
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+ software.amazon.awssdk
+ regions
+ ${project.version}
+
+
+ software.amazon.awssdk
+ sns
+ ${project.version}
+
+
+ software.amazon.awssdk
+ http-client-spi
+ ${project.version}
+
+
+ software.amazon.awssdk
+ endpoints-spi
+ ${project.version}
+
+
+ org.apache.httpcomponents.client5
+ httpclient5
+ ${httpcomponents.client5.version}
+
+
+ software.amazon.awssdk
+ apache5-client
+ ${project.version}
+ runtime
+
+
+ org.assertj
+ assertj-core
+ test
+
+
+ nl.jqno.equalsverifier
+ equalsverifier
+ test
+
+
+ mockito-core
+ org.mockito
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+
+
+
+ software.amazon.awssdk.messagemanager.sns
+
+
+
+
+
+
+
+
diff --git a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/SnsMessageManager.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/SnsMessageManager.java
new file mode 100644
index 000000000000..8f255dbc3759
--- /dev/null
+++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/SnsMessageManager.java
@@ -0,0 +1,135 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.messagemanager.sns;
+
+import java.io.InputStream;
+import software.amazon.awssdk.annotations.SdkPublicApi;
+import software.amazon.awssdk.http.SdkHttpClient;
+import software.amazon.awssdk.messagemanager.sns.internal.DefaultSnsMessageManager;
+import software.amazon.awssdk.messagemanager.sns.model.SnsMessage;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.utils.SdkAutoCloseable;
+
+
+/**
+ * Message manager for validating SNS message signatures. Create an instance using {@link #builder()}.
+ *
+ * This manager provides automatic validation of SNS message signatures received via HTTP/HTTPS endpoints,
+ * ensuring that messages originate from Amazon SNS and have not been modified during transmission.
+ * It supports both SignatureVersion1 (SHA1) and SignatureVersion2 (SHA256) as per AWS SNS standards.
+ *
+ *
The manager handles certificate retrieval, caching, and validation automatically, supporting different
+ * AWS regions and partitions (aws, aws-gov, aws-cn).
+ *
+ *
Basic usage with default configuration:
+ *
+ * {@code
+ * SnsMessageManager messageManager = SnsMessageManager.builder().build();
+ *
+ * try {
+ * SnsMessage validatedMessage = messageManager.parseMessage(messageBody);
+ * String messageContent = validatedMessage.message();
+ * String topicArn = validatedMessage.topicArn();
+ * // Process the validated message
+ * } catch (SdkClientException e) {
+ * // Handle validation failure
+ * logger.error("SNS message validation failed: {}", e.getMessage());
+ * }
+ * }
+ *
+ *
+ * Advanced usage with custom HTTP client:
+ *
+ * {@code
+ * SnsMessageManager messageManager = SnsMessageManager.builder()
+ * .httpClient(ApacheHttpClient.create())
+ * .build();
+ * }
+ *
+ *
+ * @see SnsMessage
+ * @see Builder
+ */
+@SdkPublicApi
+public interface SnsMessageManager extends SdkAutoCloseable {
+
+ /**
+ * Creates a builder for configuring and creating an {@link SnsMessageManager}.
+ *
+ * @return A new builder.
+ */
+ static Builder builder() {
+ return DefaultSnsMessageManager.builder();
+ }
+
+ /**
+ * Parses and validates an SNS message from a stream.
+ *
+ * This method reads the JSON message payload, validates the signature, returns a parsed SNS message object with all
+ * message attributes if validation succeeds.
+ *
+ * @param messageStream The binary stream representation of the SNS message.
+ * @return The parsed SNS message.
+ */
+ SnsMessage parseMessage(InputStream messageStream);
+
+ /**
+ * Parses and validates an SNS message from a string.
+ *
+ * This method reads the JSON message payload, validates the signature, returns a parsed SNS message object with all
+ * message attributes if validation succeeds.
+ *
+ * @param messageContent The string representation of the SNS message.
+ * @return the parsed SNS message.
+ */
+ SnsMessage parseMessage(String messageContent);
+
+ /**
+ * Close this {@code SnsMessageManager}, releasing any resources it owned.
+ *
+ * Note: if you provided your own {@link SdkHttpClient}, you must close it separately.
+ */
+ @Override
+ void close();
+
+ interface Builder {
+
+ /**
+ * Sets the HTTP client to use for certificate retrieval. The caller is responsible for closing this HTTP client after
+ * the {@code SnsMessageManager} is closed.
+ *
+ * @param httpClient The HTTP client to use for fetching signing certificates.
+ * @return This builder for method chaining.
+ */
+ Builder httpClient(SdkHttpClient httpClient);
+
+ /**
+ * Sets the AWS region for certificate validation. This region must match the SNS region where the messages originate.
+ * This is a required parameter.
+ *
+ * @param region The AWS region where the SNS messages originate.
+ * @return This builder for method chaining.
+ */
+ Builder region(Region region);
+
+ /**
+ * Builds an instance of {@link SnsMessageManager} based on the supplied configurations.
+ *
+ * @return An initialized SnsMessageManager ready to validate SNS messages.
+ */
+ SnsMessageManager build();
+ }
+}
\ No newline at end of file
diff --git a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateRetriever.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateRetriever.java
new file mode 100644
index 000000000000..4720c2859767
--- /dev/null
+++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateRetriever.java
@@ -0,0 +1,168 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.messagemanager.sns.internal;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.security.PublicKey;
+import java.security.cert.CertificateException;
+import java.security.cert.CertificateExpiredException;
+import java.security.cert.CertificateFactory;
+import java.security.cert.CertificateNotYetValidException;
+import java.security.cert.X509Certificate;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.net.ssl.SSLException;
+import org.apache.hc.client5.http.ssl.DefaultHostnameVerifier;
+import org.apache.hc.client5.http.ssl.HttpClientHostnameVerifier;
+import software.amazon.awssdk.annotations.SdkInternalApi;
+import software.amazon.awssdk.core.exception.SdkClientException;
+import software.amazon.awssdk.http.HttpExecuteRequest;
+import software.amazon.awssdk.http.HttpExecuteResponse;
+import software.amazon.awssdk.http.SdkHttpClient;
+import software.amazon.awssdk.http.SdkHttpMethod;
+import software.amazon.awssdk.http.SdkHttpRequest;
+import software.amazon.awssdk.utils.IoUtils;
+import software.amazon.awssdk.utils.Lazy;
+import software.amazon.awssdk.utils.SdkAutoCloseable;
+import software.amazon.awssdk.utils.Validate;
+import software.amazon.awssdk.utils.cache.lru.LruCache;
+
+/**
+ * Internal certificate retriever for SNS message validation.
+ *
+ * This class retrieves the certificate used to sign a message, validates it, and caches them for future use.
+ */
+@SdkInternalApi
+public class CertificateRetriever implements SdkAutoCloseable {
+ private static final Lazy X509_FORMAT = new Lazy<>(() ->
+ Pattern.compile(
+ "^[\\s]*-----BEGIN [A-Z]+-----\\n[A-Za-z\\d+\\/\\n]+[=]{0,2}\\n-----END [A-Z]+-----[\\s]*$"));
+
+ private final HttpClientHostnameVerifier hostnameVerifier = new DefaultHostnameVerifier();
+
+ private final SdkHttpClient httpClient;
+ private final String certCommonName;
+ private final CertificateUrlValidator certUrlValidator;
+ private final LruCache certificateCache;
+
+ public CertificateRetriever(SdkHttpClient httpClient, String certHost, String certCommonName) {
+ this(httpClient, certCommonName, new CertificateUrlValidator(certHost));
+ }
+
+ CertificateRetriever(SdkHttpClient httpClient, String certCommonName, CertificateUrlValidator certificateUrlValidator) {
+ this.httpClient = Validate.paramNotNull(httpClient, "httpClient");
+ this.certCommonName = Validate.paramNotNull(certCommonName, "certCommonName");
+ this.certificateCache = LruCache.builder(this::fetchCertificate)
+ .maxSize(10)
+ .build();
+ this.certUrlValidator = Validate.paramNotNull(certificateUrlValidator, "certificateUrlValidator");
+ }
+
+ public PublicKey retrieveCertificate(URI certificateUrl) {
+ Validate.paramNotNull(certificateUrl, "certificateUrl");
+ certUrlValidator.validate(certificateUrl);
+ return certificateCache.get(certificateUrl);
+ }
+
+ @Override
+ public void close() {
+ httpClient.close();
+ }
+
+ private PublicKey fetchCertificate(URI certificateUrl) {
+ byte[] cert = fetchUrl(certificateUrl);
+ validateCertificateData(cert);
+ return createPublicKey(cert);
+ }
+
+ private byte[] fetchUrl(URI certificateUrl) {
+ SdkHttpRequest httpRequest = SdkHttpRequest.builder()
+ .method(SdkHttpMethod.GET)
+ .uri(certificateUrl)
+ .build();
+
+ HttpExecuteRequest executeRequest = HttpExecuteRequest.builder()
+ .request(httpRequest)
+ .build();
+
+ try {
+ HttpExecuteResponse response = httpClient.prepareRequest(executeRequest).call();
+
+ if (!response.httpResponse().isSuccessful()) {
+ throw SdkClientException.create("Request was unsuccessful. Status Code: " + response.httpResponse().statusCode());
+ }
+
+ return readResponse(response);
+ } catch (SdkClientException e) {
+ throw e;
+ } catch (Exception e) {
+ throw SdkClientException.create("Unexpected error while retrieving URL: " + certificateUrl, e);
+ }
+ }
+
+ private byte[] readResponse(HttpExecuteResponse response) throws IOException {
+ try (InputStream inputStream = response.responseBody().orElseThrow(
+ () -> SdkClientException.create("Response body is empty"))) {
+
+ return IoUtils.toByteArray(inputStream);
+ }
+ }
+
+ private PublicKey createPublicKey(byte[] cert) {
+ try {
+ CertificateFactory fact = CertificateFactory.getInstance("X.509");
+ InputStream stream = new ByteArrayInputStream(cert);
+ X509Certificate cer = (X509Certificate) fact.generateCertificate(stream);
+ validateCertificate(cer, certCommonName);
+ return cer.getPublicKey();
+ } catch (CertificateExpiredException e) {
+ throw SdkClientException.create("The certificate is expired", e);
+ } catch (CertificateNotYetValidException e) {
+ throw SdkClientException.create("The certificate is not yet valid", e);
+ } catch (CertificateException e) {
+ throw SdkClientException.create("The certificate could not be parsed", e);
+ }
+ }
+
+ /**
+ * Check that the certificate is valid and that the principal is actually SNS.
+ */
+ private void validateCertificate(X509Certificate cer, String expectedCommonName) throws CertificateExpiredException,
+ CertificateNotYetValidException {
+ verifyHostname(cer, expectedCommonName);
+ cer.checkValidity();
+ }
+
+ private void verifyHostname(X509Certificate cer, String expectedCertCommonName) {
+ try {
+ hostnameVerifier.verify(expectedCertCommonName, cer);
+ } catch (SSLException e) {
+ throw SdkClientException.create("Certificate does not match expected common name: "
+ + expectedCertCommonName, e);
+ }
+ }
+
+ private void validateCertificateData(byte[] data) {
+ Matcher m = X509_FORMAT.getValue().matcher(new String(data, StandardCharsets.UTF_8));
+ if (!m.matches()) {
+ throw SdkClientException.create("Certificate does not match expected X509 PEM format.");
+ }
+ }
+}
\ No newline at end of file
diff --git a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateUrlValidator.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateUrlValidator.java
new file mode 100644
index 000000000000..cc5902506201
--- /dev/null
+++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateUrlValidator.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.messagemanager.sns.internal;
+
+import java.net.URI;
+import software.amazon.awssdk.annotations.SdkInternalApi;
+import software.amazon.awssdk.core.exception.SdkClientException;
+import software.amazon.awssdk.utils.Validate;
+
+/**
+ * Validates that the signing certificate URL is valid.
+ */
+@SdkInternalApi
+public class CertificateUrlValidator {
+ private final String certificateHost;
+
+ public CertificateUrlValidator(String certificateHost) {
+ Validate.notBlank(certificateHost, "Expected certificate host cannot be null or empty");
+ this.certificateHost = certificateHost;
+ }
+
+ public void validate(URI certificateUrl) {
+ if (certificateUrl == null) {
+ throw SdkClientException.create("Certificate URL cannot be null");
+ }
+
+ if (!"https".equals(certificateUrl.getScheme())) {
+ throw SdkClientException.create("Certificate URL must use HTTPS");
+ }
+
+ if (!certificateHost.equals(certificateUrl.getHost())) {
+ throw SdkClientException.create("Certificate URL does not match expected host: " + certificateHost);
+ }
+ }
+}
diff --git a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/DefaultSnsMessageManager.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/DefaultSnsMessageManager.java
new file mode 100644
index 000000000000..5f9ceb1b88c6
--- /dev/null
+++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/DefaultSnsMessageManager.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.messagemanager.sns.internal;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.security.PublicKey;
+import java.time.Duration;
+import software.amazon.awssdk.annotations.SdkInternalApi;
+import software.amazon.awssdk.annotations.SdkTestInternalApi;
+import software.amazon.awssdk.core.internal.http.loader.DefaultSdkHttpClientBuilder;
+import software.amazon.awssdk.http.SdkHttpClient;
+import software.amazon.awssdk.http.SdkHttpConfigurationOption;
+import software.amazon.awssdk.messagemanager.sns.SnsMessageManager;
+import software.amazon.awssdk.messagemanager.sns.model.SnsMessage;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.utils.AttributeMap;
+import software.amazon.awssdk.utils.Validate;
+
+@SdkInternalApi
+public final class DefaultSnsMessageManager implements SnsMessageManager {
+ private static final AttributeMap HTTP_CLIENT_DEFAULTS =
+ AttributeMap.builder()
+ .put(SdkHttpConfigurationOption.CONNECTION_TIMEOUT, Duration.ofSeconds(10))
+ .put(SdkHttpConfigurationOption.READ_TIMEOUT, Duration.ofSeconds(30))
+ .build();
+
+ private final SnsMessageUnmarshaller unmarshaller;
+ private final CertificateRetriever certRetriever;
+ private final SignatureValidator signatureValidator;
+
+ private DefaultSnsMessageManager(BuilderImpl builder) {
+ this.unmarshaller = new SnsMessageUnmarshaller();
+
+ SnsHostProvider hostProvider = new SnsHostProvider(builder.region);
+ URI signingCertEndpoint = hostProvider.regionalEndpoint();
+ String signingCertCommonName = hostProvider.signingCertCommonName();
+
+ SdkHttpClient httpClient = resolveHttpClient(builder);
+ certRetriever = builder.certRetriever != null
+ ? builder.certRetriever
+ : new CertificateRetriever(httpClient, signingCertEndpoint.getHost(), signingCertCommonName);
+
+ signatureValidator = new SignatureValidator();
+ }
+
+ @Override
+ public SnsMessage parseMessage(InputStream message) {
+ Validate.notNull(message, "message cannot be null");
+
+ SnsMessage snsMessage = unmarshaller.unmarshall(message);
+ PublicKey certificate = certRetriever.retrieveCertificate(snsMessage.signingCertUrl());
+
+ signatureValidator.validateSignature(snsMessage, certificate);
+
+ return snsMessage;
+ }
+
+ @Override
+ public SnsMessage parseMessage(String message) {
+ Validate.notNull(message, "message cannot be null");
+ return parseMessage(new ByteArrayInputStream(message.getBytes(StandardCharsets.UTF_8)));
+ }
+
+ @Override
+ public void close() {
+ certRetriever.close();
+ }
+
+ public static Builder builder() {
+ return new BuilderImpl();
+ }
+
+ private static SdkHttpClient resolveHttpClient(BuilderImpl builder) {
+ if (builder.httpClient != null) {
+ return new UnmanagedSdkHttpClient(builder.httpClient);
+ }
+
+ return new DefaultSdkHttpClientBuilder().buildWithDefaults(HTTP_CLIENT_DEFAULTS);
+ }
+
+ static class BuilderImpl implements SnsMessageManager.Builder {
+ private Region region;
+ private SdkHttpClient httpClient;
+
+ // Testing only
+ private CertificateRetriever certRetriever;
+
+ @Override
+ public Builder httpClient(SdkHttpClient httpClient) {
+ this.httpClient = httpClient;
+ return this;
+ }
+
+ @Override
+ public Builder region(Region region) {
+ this.region = region;
+ return this;
+ }
+
+ @SdkTestInternalApi
+ Builder certificateRetriever(CertificateRetriever certificateRetriever) {
+ this.certRetriever = certificateRetriever;
+ return this;
+ }
+
+ @Override
+ public SnsMessageManager build() {
+ return new DefaultSnsMessageManager(this);
+ }
+ }
+}
diff --git a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/SignatureValidator.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/SignatureValidator.java
new file mode 100644
index 000000000000..963e6901d930
--- /dev/null
+++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/SignatureValidator.java
@@ -0,0 +1,171 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.messagemanager.sns.internal;
+
+import java.nio.charset.StandardCharsets;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.security.PublicKey;
+import java.security.Signature;
+import java.security.SignatureException;
+import java.util.StringJoiner;
+import software.amazon.awssdk.annotations.SdkInternalApi;
+import software.amazon.awssdk.core.SdkBytes;
+import software.amazon.awssdk.core.exception.SdkClientException;
+import software.amazon.awssdk.messagemanager.sns.model.SignatureVersion;
+import software.amazon.awssdk.messagemanager.sns.model.SnsMessage;
+import software.amazon.awssdk.messagemanager.sns.model.SnsNotification;
+import software.amazon.awssdk.messagemanager.sns.model.SnsSubscriptionConfirmation;
+import software.amazon.awssdk.messagemanager.sns.model.SnsUnsubscribeConfirmation;
+import software.amazon.awssdk.utils.Logger;
+import software.amazon.awssdk.utils.Validate;
+
+/**
+ * See
+ *
+ * The official documentation.
+ */
+@SdkInternalApi
+public final class SignatureValidator {
+ private static final Logger LOG = Logger.loggerFor(SignatureValidator.class);
+
+ private static final String MESSAGE = "Message";
+ private static final String MESSAGE_ID = "MessageId";
+ private static final String SUBJECT = "Subject";
+ private static final String SUBSCRIBE_URL = "SubscribeURL";
+ private static final String TIMESTAMP = "Timestamp";
+ private static final String TOKEN = "Token";
+ private static final String TOPIC_ARN = "TopicArn";
+ private static final String TYPE = "Type";
+
+ private static final String NEWLINE = "\n";
+
+ public void validateSignature(SnsMessage message, PublicKey publicKey) {
+ Validate.paramNotNull(message, "message");
+ Validate.paramNotNull(publicKey, "publicKey");
+
+ SdkBytes messageSignature = message.signature();
+ if (messageSignature == null) {
+ throw SdkClientException.create("Message signature cannot be null");
+ }
+
+ SignatureVersion signatureVersion = message.signatureVersion();
+ if (signatureVersion == null) {
+ throw SdkClientException.create("Message signature version cannot be null");
+ }
+
+ if (message.timestamp() == null) {
+ throw SdkClientException.create("Message timestamp cannot be null");
+ }
+
+ String canonicalMessage = buildCanonicalMessage(message);
+ LOG.debug(() -> String.format("Canonical message: %s%n", canonicalMessage));
+
+ Signature signature = getSignature(signatureVersion);
+
+ verifySignature(canonicalMessage, messageSignature, publicKey, signature);
+ }
+
+ private static String buildCanonicalMessage(SnsMessage message) {
+ switch (message.type()) {
+ case NOTIFICATION:
+ return buildCanonicalMessage((SnsNotification) message);
+ case SUBSCRIPTION_CONFIRMATION:
+ return buildCanonicalMessage((SnsSubscriptionConfirmation) message);
+ case UNSUBSCRIBE_CONFIRMATION:
+ return buildCanonicalMessage((SnsUnsubscribeConfirmation) message);
+ default:
+ throw new IllegalStateException(String.format("Unsupported SNS message type: %s", message.type()));
+ }
+ }
+
+ private static String buildCanonicalMessage(SnsNotification notification) {
+ StringJoiner joiner = new StringJoiner(NEWLINE, "", NEWLINE);
+ joiner.add(MESSAGE).add(notification.message());
+ joiner.add(MESSAGE_ID).add(notification.messageId());
+
+ if (notification.subject() != null) {
+ joiner.add(SUBJECT).add(notification.subject());
+ }
+
+ joiner.add(TIMESTAMP).add(notification.timestamp().toString());
+ joiner.add(TOPIC_ARN).add(notification.topicArn());
+ joiner.add(TYPE).add(notification.type().toString());
+
+ return joiner.toString();
+ }
+
+ // Message, MessageId, SubscribeURL, Timestamp, Token, TopicArn, and Type.
+ private static String buildCanonicalMessage(SnsSubscriptionConfirmation message) {
+ StringJoiner joiner = new StringJoiner(NEWLINE, "", NEWLINE);
+ joiner.add(MESSAGE).add(message.message());
+ joiner.add(MESSAGE_ID).add(message.messageId());
+ joiner.add(SUBSCRIBE_URL).add(message.subscribeUrl().toString());
+ joiner.add(TIMESTAMP).add(message.timestamp().toString());
+ joiner.add(TOKEN).add(message.token());
+ joiner.add(TOPIC_ARN).add(message.topicArn());
+ joiner.add(TYPE).add(message.type().toString());
+
+ return joiner.toString();
+ }
+
+ private static String buildCanonicalMessage(SnsUnsubscribeConfirmation message) {
+ StringJoiner joiner = new StringJoiner(NEWLINE, "", NEWLINE);
+ joiner.add(MESSAGE).add(message.message());
+ joiner.add(MESSAGE_ID).add(message.messageId());
+ joiner.add(SUBSCRIBE_URL).add(message.subscribeUrl().toString());
+ joiner.add(TIMESTAMP).add(message.timestamp().toString());
+ joiner.add(TOKEN).add(message.token());
+ joiner.add(TOPIC_ARN).add(message.topicArn());
+ joiner.add(TYPE).add(message.type().toString());
+
+ return joiner.toString();
+ }
+
+ private static void verifySignature(String canonicalMessage, SdkBytes messageSignature, PublicKey publicKey,
+ Signature signature) {
+
+ try {
+ signature.initVerify(publicKey);
+ signature.update(canonicalMessage.getBytes(StandardCharsets.UTF_8));
+
+ boolean isValid = signature.verify(messageSignature.asByteArray());
+
+ if (!isValid) {
+ throw SdkClientException.create("The computed signature did not match the expected signature");
+ }
+ } catch (InvalidKeyException e) {
+ throw SdkClientException.create("The public key is invalid", e);
+ } catch (SignatureException e) {
+ throw SdkClientException.create("The signature is invalid", e);
+ }
+ }
+
+ private static Signature getSignature(SignatureVersion signatureVersion) {
+ try {
+ switch (signatureVersion) {
+ case VERSION_1:
+ return Signature.getInstance("SHA1withRSA");
+ case VERSION_2:
+ return Signature.getInstance("SHA256withRSA");
+ default:
+ throw new IllegalArgumentException("Unsupported signature version: " + signatureVersion);
+ }
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException("Unable to create Signature for " + signatureVersion, e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/SnsHostProvider.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/SnsHostProvider.java
new file mode 100644
index 000000000000..afe5c41e620e
--- /dev/null
+++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/SnsHostProvider.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.messagemanager.sns.internal;
+
+import java.net.URI;
+import software.amazon.awssdk.annotations.SdkInternalApi;
+import software.amazon.awssdk.annotations.SdkTestInternalApi;
+import software.amazon.awssdk.annotations.ThreadSafe;
+import software.amazon.awssdk.core.exception.SdkClientException;
+import software.amazon.awssdk.endpoints.Endpoint;
+import software.amazon.awssdk.regions.PartitionMetadata;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.sns.endpoints.SnsEndpointParams;
+import software.amazon.awssdk.services.sns.endpoints.SnsEndpointProvider;
+import software.amazon.awssdk.utils.CompletableFutureUtils;
+import software.amazon.awssdk.utils.Logger;
+import software.amazon.awssdk.utils.Validate;
+
+/**
+ * Utility class for determining both the regional endpoint that SNS certificates are expected to be hosted from, as well as the
+ * expected common name (CN) that the certificate from that endpoint must have.
+ */
+@SdkInternalApi
+@ThreadSafe
+public class SnsHostProvider {
+ private static final Logger LOG = Logger.loggerFor(SnsHostProvider.class);
+
+ private final Region region;
+ private final SnsEndpointProvider endpointProvider;
+
+ public SnsHostProvider(Region region) {
+ this(region, SnsEndpointProvider.defaultProvider());
+ }
+
+ @SdkTestInternalApi
+ SnsHostProvider(Region region, SnsEndpointProvider endpointProvider) {
+ Validate.notNull(region, "region must not be null");
+ Validate.notNull(endpointProvider, "endpointProvider must not be null");
+ this.region = region;
+ this.endpointProvider = endpointProvider;
+ }
+
+ public URI regionalEndpoint() {
+ SnsEndpointParams params = SnsEndpointParams.builder().region(region).build();
+ try {
+ Endpoint endpoint = CompletableFutureUtils.joinLikeSync(endpointProvider.resolveEndpoint(params));
+ URI url = endpoint.url();
+ LOG.debug(() -> String.format("Resolved endpoint %s for region %s", url, region));
+ return url;
+ } catch (SdkClientException e) {
+ throw SdkClientException.create("Unable to resolve SNS endpoint for region " + region, e);
+ }
+ }
+
+ public String signingCertCommonName() {
+ String commonName = signingCertCommonNameInternal();
+ LOG.debug(() -> String.format("Resolved common name %s for region %s", commonName, region));
+ return commonName;
+ }
+
+ private String signingCertCommonNameInternal() {
+ // If we don't know about this region, try to guess common name
+ if (!Region.regions().contains(region)) {
+ // Find the partition where it belongs by checking the region against the published pattern for known partitions.
+ // e.g. 'us-gov-west-3' would match the 'aws-us-gov' partition.
+ // This will return the 'aws' partition if it fails to match any partition.
+ PartitionMetadata partitionMetadata = PartitionMetadata.of(region);
+ return "sns." + partitionMetadata.dnsSuffix();
+ }
+
+ String regionId = region.id();
+
+ switch (regionId) {
+ case "cn-north-1":
+ return "sns-cn-north-1.amazonaws.com.cn";
+ case "cn-northwest-1":
+ return "sns-cn-northwest-1.amazonaws.com.cn";
+ case "us-gov-west-1":
+ case "us-gov-east-1":
+ return "sns-us-gov-west-1.amazonaws.com";
+ case "us-iso-east-1":
+ return "sns-us-iso-east-1.c2s.ic.gov";
+ case "us-isob-east-1":
+ return "sns-us-isob-east-1.sc2s.sgov.gov";
+ case "us-isof-east-1":
+ return "sns-signing.us-isof-east-1.csp.hci.ic.gov";
+ case "us-isof-south-1":
+ return "sns-signing.us-isof-south-1.csp.hci.ic.gov";
+ case "eu-isoe-west-1":
+ return "sns-signing.eu-isoe-west-1.cloud.adc-e.uk";
+ case "eusc-de-east-1":
+ return "sns-signing.eusc-de-east-1.amazonaws.eu";
+ case "ap-east-1":
+ case "ap-east-2":
+ case "ap-south-2":
+ case "ap-southeast-5":
+ case "ap-southeast-6":
+ case "ap-southeast-7":
+ case "me-south-1":
+ case "me-central-1":
+ case "eu-south-1":
+ case "eu-south-2":
+ case "eu-central-2":
+ case "af-south-1":
+ case "ap-southeast-3":
+ case "ap-southeast-4":
+ case "il-central-1":
+ case "ca-west-1":
+ case "mx-central-1":
+ return "sns-signing." + regionId + ".amazonaws.com";
+ default:
+ return "sns.amazonaws.com";
+ }
+ }
+}
diff --git a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/SnsMessageUnmarshaller.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/SnsMessageUnmarshaller.java
new file mode 100644
index 000000000000..c8b168e9f23d
--- /dev/null
+++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/SnsMessageUnmarshaller.java
@@ -0,0 +1,170 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.messagemanager.sns.internal;
+
+import java.io.InputStream;
+import java.net.URI;
+import java.time.Instant;
+import java.time.format.DateTimeParseException;
+import java.util.Optional;
+import software.amazon.awssdk.annotations.SdkInternalApi;
+import software.amazon.awssdk.core.SdkBytes;
+import software.amazon.awssdk.core.exception.SdkClientException;
+import software.amazon.awssdk.messagemanager.sns.model.SignatureVersion;
+import software.amazon.awssdk.messagemanager.sns.model.SnsMessage;
+import software.amazon.awssdk.messagemanager.sns.model.SnsMessageType;
+import software.amazon.awssdk.messagemanager.sns.model.SnsNotification;
+import software.amazon.awssdk.messagemanager.sns.model.SnsSubscriptionConfirmation;
+import software.amazon.awssdk.messagemanager.sns.model.SnsUnsubscribeConfirmation;
+import software.amazon.awssdk.protocols.jsoncore.JsonNode;
+import software.amazon.awssdk.protocols.jsoncore.JsonNodeParser;
+import software.amazon.awssdk.utils.BinaryUtils;
+
+@SdkInternalApi
+public class SnsMessageUnmarshaller {
+ private static final String MESSAGE_FIELD = "Message";
+ private static final String MESSAGE_ID_FIELD = "MessageId";
+ private static final String SIGNATURE_FIELD = "Signature";
+ private static final String SIGNATURE_VERSION_FIELD = "SignatureVersion";
+ private static final String SIGNING_CERT_URL = "SigningCertURL";
+ private static final String SUBJECT_FIELD = "Subject";
+ private static final String SUBSCRIBE_URL = "SubscribeURL";
+ private static final String TOKEN_FIELD = "Token";
+ private static final String TOPIC_ARN_FIELD = "TopicArn";
+ private static final String TIMESTAMP_FIELD = "Timestamp";
+ private static final String TYPE_FIELD = "Type";
+ private static final String UNSUBSCRIBE_URL_FIELD = "UnsubscribeURL";
+
+ private static final JsonNodeParser PARSER = JsonNodeParser.builder()
+ .removeErrorLocations(true)
+ .build();
+
+ public SnsMessage unmarshall(InputStream stream) {
+ JsonNode node = PARSER.parse(stream);
+
+ if (!node.isObject()) {
+ throw SdkClientException.create("Expected an JSON object");
+ }
+
+ Optional type = stringMember(node, TYPE_FIELD);
+
+ if (!type.isPresent()) {
+ throw SdkClientException.create("'Type' field must be present");
+ }
+
+ SnsMessageType snsMessageType = SnsMessageType.fromValue(type.get());
+ switch (snsMessageType) {
+ case NOTIFICATION: {
+ SnsNotification.Builder builder = SnsNotification.builder();
+ unmarshallNotification(node, builder);
+ return builder.build();
+ }
+ case SUBSCRIPTION_CONFIRMATION: {
+ SnsSubscriptionConfirmation.Builder builder = SnsSubscriptionConfirmation.builder();
+ unmarshallSubscriptionConfirmation(node, builder);
+ return builder.build();
+ }
+ case UNSUBSCRIBE_CONFIRMATION: {
+ SnsUnsubscribeConfirmation.Builder builder = SnsUnsubscribeConfirmation.builder();
+ unmarshallUnsubscribeNotification(node, builder);
+ return builder.build();
+ }
+ default:
+ throw SdkClientException.create("Unsupported sns message type: " + snsMessageType);
+ }
+ }
+
+ private Optional stringMember(JsonNode obj, String fieldName) {
+ Optional memberOptional = obj.field(fieldName);
+
+ if (!memberOptional.isPresent()) {
+ return Optional.empty();
+ }
+
+ JsonNode node = memberOptional.get();
+ if (!node.isString()) {
+ String msg = String.format("Expected field '%s' to be a string", fieldName);
+ throw SdkClientException.create(msg);
+ }
+
+ return Optional.of(node.asString());
+ }
+
+ private void unmarshallNotification(JsonNode node, SnsNotification.Builder builder) {
+ stringMember(node, SUBJECT_FIELD).ifPresent(builder::subject);
+ stringMember(node, UNSUBSCRIBE_URL_FIELD)
+ .map(SnsMessageUnmarshaller::toUri)
+ .ifPresent(builder::unsubscribeUrl);
+
+ unmarshallCommon(node, builder);
+ }
+
+ // https://docs.aws.amazon.com/sns/latest/dg/http-subscription-confirmation-json.html
+ private void unmarshallSubscriptionConfirmation(JsonNode node, SnsSubscriptionConfirmation.Builder builder) {
+ stringMember(node, TOKEN_FIELD).ifPresent(builder::token);
+ stringMember(node, SUBSCRIBE_URL).map(SnsMessageUnmarshaller::toUri).ifPresent(builder::subscribeUrl);
+ unmarshallCommon(node, builder);
+ }
+
+ // https://docs.aws.amazon.com/sns/latest/dg/http-unsubscribe-confirmation-json.html
+ private void unmarshallUnsubscribeNotification(JsonNode node, SnsUnsubscribeConfirmation.Builder builder) {
+ stringMember(node, TOKEN_FIELD).ifPresent(builder::token);
+ stringMember(node, SUBSCRIBE_URL).map(SnsMessageUnmarshaller::toUri).ifPresent(builder::subscribeUrl);
+ unmarshallCommon(node, builder);
+ }
+
+ private void unmarshallCommon(JsonNode node, SnsMessage.Builder> builder) {
+ stringMember(node, MESSAGE_ID_FIELD).ifPresent(builder::messageId);
+ stringMember(node, MESSAGE_FIELD).ifPresent(builder::message);
+ stringMember(node, TOPIC_ARN_FIELD).ifPresent(builder::topicArn);
+
+ Optional timestamp = stringMember(node, TIMESTAMP_FIELD);
+ if (timestamp.isPresent()) {
+ try {
+ Instant instant = Instant.parse(timestamp.get());
+ builder.timestamp(instant);
+ } catch (DateTimeParseException e) {
+ throw SdkClientException.create("Unable to parse timestamp", e);
+ }
+ }
+
+ Optional signatureField = stringMember(node, SIGNATURE_FIELD);
+ if (signatureField.isPresent()) {
+ try {
+ byte[] decoded = BinaryUtils.fromBase64(signatureField.get());
+ builder.signature(SdkBytes.fromByteArray(decoded));
+ } catch (IllegalArgumentException e) {
+ throw SdkClientException.create("Unable to decode signature", e);
+ }
+ }
+
+ stringMember(node, SIGNATURE_VERSION_FIELD)
+ .map(SignatureVersion::fromValue)
+ .ifPresent(builder::signatureVersion);
+
+ stringMember(node, SIGNING_CERT_URL)
+ .map(SnsMessageUnmarshaller::toUri)
+ .ifPresent(builder::signingCertUrl);
+ }
+
+ private static URI toUri(String s) {
+ try {
+ return URI.create(s);
+ } catch (IllegalArgumentException e) {
+ throw SdkClientException.create("Unable to parse URI", e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/UnmanagedSdkHttpClient.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/UnmanagedSdkHttpClient.java
new file mode 100644
index 000000000000..97dbfa867a41
--- /dev/null
+++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/UnmanagedSdkHttpClient.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.messagemanager.sns.internal;
+
+import software.amazon.awssdk.annotations.SdkInternalApi;
+import software.amazon.awssdk.http.ExecutableHttpRequest;
+import software.amazon.awssdk.http.HttpExecuteRequest;
+import software.amazon.awssdk.http.SdkHttpClient;
+
+@SdkInternalApi
+final class UnmanagedSdkHttpClient implements SdkHttpClient {
+ private final SdkHttpClient delegate;
+
+ UnmanagedSdkHttpClient(SdkHttpClient delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public ExecutableHttpRequest prepareRequest(HttpExecuteRequest request) {
+ return delegate.prepareRequest(request);
+ }
+
+ @Override
+ public void close() {
+ // no-op, owner of delegate is responsible for closing.
+ }
+}
diff --git a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SignatureVersion.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SignatureVersion.java
new file mode 100644
index 000000000000..971ad09975b8
--- /dev/null
+++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SignatureVersion.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.messagemanager.sns.model;
+
+import java.util.Objects;
+import software.amazon.awssdk.annotations.SdkPublicApi;
+
+/**
+ * The signature version used to sign an SNS message.
+ */
+@SdkPublicApi
+public enum SignatureVersion {
+ VERSION_1("1"),
+ VERSION_2("2"),
+ UNKNOWN(null)
+ ;
+
+ private final String value;
+
+ SignatureVersion(String value) {
+ this.value = value;
+ }
+
+ @Override
+ public String toString() {
+ return value;
+ }
+
+ public static SignatureVersion fromValue(String value) {
+ for (SignatureVersion v : values()) {
+ if (Objects.equals(v.value, value)) {
+ return v;
+ }
+ }
+
+ return UNKNOWN;
+ }
+}
diff --git a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsMessage.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsMessage.java
new file mode 100644
index 000000000000..b56ba151a3ee
--- /dev/null
+++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsMessage.java
@@ -0,0 +1,238 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.messagemanager.sns.model;
+
+import java.net.URI;
+import java.time.Instant;
+import java.util.Objects;
+import software.amazon.awssdk.annotations.SdkPublicApi;
+import software.amazon.awssdk.core.SdkBytes;
+import software.amazon.awssdk.utils.ToString;
+
+/**
+ * Base class for SNS message types. This contains the common fields of SNS messages.
+ */
+@SdkPublicApi
+public abstract class SnsMessage {
+ private final String messageId;
+ private final String message;
+ private final String topicArn;
+ private final Instant timestamp;
+ private final SdkBytes signature;
+ private final SignatureVersion signatureVersion;
+ private final URI signingCertUrl;
+
+ protected SnsMessage(BuilderImpl> builder) {
+ this.messageId = builder.messageId;
+ this.message = builder.message;
+ this.topicArn = builder.topicArn;
+ this.timestamp = builder.timestamp;
+ this.signature = builder.signature;
+ this.signatureVersion = builder.signatureVersion;
+ this.signingCertUrl = builder.signingCertUrl;
+ }
+
+ /**
+ * The type of this message.
+ */
+ public abstract SnsMessageType type();
+
+ /**
+ * A Universally Unique Identifier (UUID), unique for each message published. For a message that Amazon SNS resends during a
+ * retry, the message ID of the original message is used.
+ */
+ public String messageId() {
+ return messageId;
+ }
+
+ /**
+ * The message body.
+ */
+ public String message() {
+ return message;
+ }
+
+ /**
+ * The Amazon Resource Name (ARN) for the topic.
+ */
+ public String topicArn() {
+ return topicArn;
+ }
+
+ /**
+ * The time (GMT) when the message was sent.
+ */
+ public Instant timestamp() {
+ return timestamp;
+ }
+
+ /**
+ * SHA1withRSA or SHA256withRSA signature of this message. The values from the message used to calculate the signature are
+ * dictated by the message type. See
+ * the service documentation
+ * for more information.
+ */
+ public SdkBytes signature() {
+ return signature;
+ }
+
+ /**
+ * Version of the Amazon SNS signature used.
+ */
+ public SignatureVersion signatureVersion() {
+ return signatureVersion;
+ }
+
+ /**
+ * The URL to the certificate that was used to sign the message.
+ */
+ public URI signingCertUrl() {
+ return signingCertUrl;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+
+ SnsMessage that = (SnsMessage) o;
+ return Objects.equals(messageId, that.messageId)
+ && Objects.equals(message, that.message)
+ && Objects.equals(topicArn, that.topicArn)
+ && Objects.equals(timestamp, that.timestamp)
+ && Objects.equals(signature, that.signature)
+ && signatureVersion == that.signatureVersion
+ && Objects.equals(signingCertUrl, that.signingCertUrl);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = Objects.hashCode(messageId);
+ result = 31 * result + Objects.hashCode(message);
+ result = 31 * result + Objects.hashCode(topicArn);
+ result = 31 * result + Objects.hashCode(timestamp);
+ result = 31 * result + Objects.hashCode(signature);
+ result = 31 * result + Objects.hashCode(signatureVersion);
+ result = 31 * result + Objects.hashCode(signingCertUrl);
+ return result;
+ }
+
+ protected ToString toStringBuilder(String className) {
+ return ToString.builder(className)
+ .add("MessageId", messageId())
+ .add("Message", message())
+ .add("TopicArn", topicArn())
+ .add("Timestamp", timestamp())
+ .add("Signature", signature())
+ .add("SignatureVersion", signatureVersion())
+ .add("SigningCertUrl", signingCertUrl());
+ }
+
+ public interface Builder> {
+ /**
+ * A Universally Unique Identifier (UUID), unique for each message published. For a message that Amazon SNS resends during
+ * a retry, the message ID of the original message is used.
+ */
+ SubclassT messageId(String messageId);
+
+ /**
+ * The message body.
+ */
+ SubclassT message(String message);
+
+ /**
+ * The Amazon Resource Name (ARN) for the topic.
+ */
+ SubclassT topicArn(String topicArn);
+
+ /**
+ * The time (GMT) when the message was sent.
+ */
+ SubclassT timestamp(Instant timestamp);
+
+ /**
+ * SHA1withRSA or SHA256withRSA signature of this message. The values from the message used to calculate the signature are
+ * dictated by the message type. See
+ * the service documentation
+ * for more information.
+ */
+ SubclassT signature(SdkBytes signature);
+
+ /**
+ * Version of the Amazon SNS signature used.
+ */
+ SubclassT signatureVersion(SignatureVersion signatureVersion);
+
+ /**
+ * The URL to the certificate that was used to sign the message.
+ */
+ SubclassT signingCertUrl(URI signingCertUrl);
+ }
+
+ @SuppressWarnings("unchecked")
+ protected static class BuilderImpl> implements Builder {
+ private String messageId;
+ private String message;
+ private String topicArn;
+ private Instant timestamp;
+ private SdkBytes signature;
+ private SignatureVersion signatureVersion;
+ private URI signingCertUrl;
+
+ @Override
+ public SubclassT message(String message) {
+ this.message = message;
+ return (SubclassT) this;
+ }
+
+ @Override
+ public SubclassT messageId(String messageId) {
+ this.messageId = messageId;
+ return (SubclassT) this;
+ }
+
+ @Override
+ public SubclassT topicArn(String topicArn) {
+ this.topicArn = topicArn;
+ return (SubclassT) this;
+ }
+
+ @Override
+ public SubclassT timestamp(Instant timestamp) {
+ this.timestamp = timestamp;
+ return (SubclassT) this;
+ }
+
+ @Override
+ public SubclassT signature(SdkBytes signature) {
+ this.signature = signature;
+ return (SubclassT) this;
+ }
+
+ @Override
+ public SubclassT signatureVersion(SignatureVersion signatureVersion) {
+ this.signatureVersion = signatureVersion;
+ return (SubclassT) this;
+ }
+
+ @Override
+ public SubclassT signingCertUrl(URI signingCertUrl) {
+ this.signingCertUrl = signingCertUrl;
+ return (SubclassT) this;
+ }
+ }
+}
\ No newline at end of file
diff --git a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsMessageType.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsMessageType.java
new file mode 100644
index 000000000000..e0ca27f5cfb1
--- /dev/null
+++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsMessageType.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.messagemanager.sns.model;
+
+import software.amazon.awssdk.annotations.SdkPublicApi;
+
+/**
+ * The type of message sent by SNS. This corresponds to the value of the {@code Type} field in the SNS message. See the
+ * SNS developer guide
+ * for more information.
+ */
+@SdkPublicApi
+public enum SnsMessageType {
+ SUBSCRIPTION_CONFIRMATION("SubscriptionConfirmation"),
+ NOTIFICATION("Notification"),
+ UNSUBSCRIBE_CONFIRMATION("UnsubscribeConfirmation"),
+
+ /**
+ * The type of the SNS message is unknown to this SDK version.
+ */
+ UNKNOWN("Unknown");
+
+ private final String value;
+
+ SnsMessageType(String value) {
+ this.value = value;
+ }
+
+ public static SnsMessageType fromValue(String value) {
+ for (SnsMessageType type : values()) {
+ if (type.value.equalsIgnoreCase(value)) {
+ return type;
+ }
+ }
+ return UNKNOWN;
+ }
+
+
+ @Override
+ public String toString() {
+ return value;
+ }
+}
diff --git a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsNotification.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsNotification.java
new file mode 100644
index 000000000000..8ba35fc93ce0
--- /dev/null
+++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsNotification.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.messagemanager.sns.model;
+
+import java.net.URI;
+import java.util.Objects;
+import software.amazon.awssdk.annotations.SdkPublicApi;
+
+/**
+ * A 'Notification' message from SNS.
+ *
+ * See the API reference for more
+ * information.
+ */
+@SdkPublicApi
+public final class SnsNotification extends SnsMessage {
+ private final String subject;
+ private final URI unsubscribeUrl;
+
+ SnsNotification(BuilderImpl builder) {
+ super(builder);
+ this.subject = builder.subject;
+ this.unsubscribeUrl = builder.unsubscribeUrl;
+ }
+
+ @Override
+ public SnsMessageType type() {
+ return SnsMessageType.NOTIFICATION;
+ }
+
+ /**
+ * The subject of the message. This may be {@code null}.
+ */
+ public String subject() {
+ return subject;
+ }
+
+ /**
+ * The URL that can be visited to unsubscribe from the topic.
+ */
+ public URI unsubscribeUrl() {
+ return unsubscribeUrl;
+ }
+
+ public static Builder builder() {
+ return new BuilderImpl();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ if (!super.equals(o)) {
+ return false;
+ }
+
+ SnsNotification that = (SnsNotification) o;
+ return Objects.equals(subject, that.subject)
+ && Objects.equals(unsubscribeUrl, that.unsubscribeUrl);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = super.hashCode();
+ result = 31 * result + Objects.hashCode(subject);
+ result = 31 * result + Objects.hashCode(unsubscribeUrl);
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ return toStringBuilder("SnsNotification")
+ .add("Subject", subject())
+ .add("UnsubscribeUrl", unsubscribeUrl())
+ .build();
+ }
+
+ public interface Builder extends SnsMessage.Builder {
+
+ /**
+ * The subject of the message. This may be {@code null}.
+ */
+ Builder subject(String subject);
+
+ /**
+ * The URL that can be visited to unsubscribe from the topic.
+ */
+ Builder unsubscribeUrl(URI unsubscribeUrl);
+
+ SnsNotification build();
+ }
+
+ private static class BuilderImpl extends SnsMessage.BuilderImpl implements Builder {
+ private String subject;
+ private URI unsubscribeUrl;
+
+ @Override
+ public Builder subject(String subject) {
+ this.subject = subject;
+ return this;
+ }
+
+ @Override
+ public Builder unsubscribeUrl(URI unsubscribeUrl) {
+ this.unsubscribeUrl = unsubscribeUrl;
+ return this;
+ }
+
+ @Override
+ public SnsNotification build() {
+ return new SnsNotification(this);
+ }
+ }
+}
diff --git a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsSubscriptionConfirmation.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsSubscriptionConfirmation.java
new file mode 100644
index 000000000000..697f94c88d79
--- /dev/null
+++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsSubscriptionConfirmation.java
@@ -0,0 +1,132 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.messagemanager.sns.model;
+
+import java.net.URI;
+import java.util.Objects;
+import software.amazon.awssdk.annotations.SdkPublicApi;
+
+/**
+ * A 'SubscriptionConfirmation' message from SNS.
+ *
+ * See the API reference for more
+ * information.
+ */
+@SdkPublicApi
+public final class SnsSubscriptionConfirmation extends SnsMessage {
+ private final URI subscribeUrl;
+ private final String token;
+
+ private SnsSubscriptionConfirmation(BuilderImpl builder) {
+ super(builder);
+ this.subscribeUrl = builder.subscribeUrl;
+ this.token = builder.token;
+ }
+
+ @Override
+ public SnsMessageType type() {
+ return SnsMessageType.SUBSCRIPTION_CONFIRMATION;
+ }
+
+ /**
+ * The URL that can be visited to confirm subscription to the topic.
+ */
+ public URI subscribeUrl() {
+ return subscribeUrl;
+ }
+
+ /**
+ * A value that can be used with the
+ * ConfirmSubscription
+ * API to confirm subscription to the topic.
+ */
+ public String token() {
+ return token;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ if (!super.equals(o)) {
+ return false;
+ }
+
+ SnsSubscriptionConfirmation that = (SnsSubscriptionConfirmation) o;
+ return Objects.equals(subscribeUrl, that.subscribeUrl)
+ && Objects.equals(token, that.token);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = super.hashCode();
+ result = 31 * result + Objects.hashCode(subscribeUrl);
+ result = 31 * result + Objects.hashCode(token);
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ return toStringBuilder("SnsSubscriptionConfirmation")
+ .add("SubscribeUrl", subscribeUrl())
+ .add("Token", token())
+ .build();
+ }
+
+ public static Builder builder() {
+ return new BuilderImpl();
+ }
+
+ public interface Builder extends SnsMessage.Builder {
+
+ /**
+ * The URL that can be visited to confirm subscription to the topic.
+ */
+ Builder subscribeUrl(URI subscribeUrl);
+
+ /**
+ * A value that can be used with the
+ * ConfirmSubscription
+ * API to confirm subscription to the topic.
+ */
+ Builder token(String token);
+
+ SnsSubscriptionConfirmation build();
+ }
+
+ private static class BuilderImpl extends SnsMessage.BuilderImpl implements Builder {
+ private URI subscribeUrl;
+ private String token;
+
+ @Override
+ public Builder subscribeUrl(URI subscribeUrl) {
+ this.subscribeUrl = subscribeUrl;
+ return this;
+ }
+
+ @Override
+ public Builder token(String token) {
+ this.token = token;
+ return this;
+ }
+
+ @Override
+ public SnsSubscriptionConfirmation build() {
+ return new SnsSubscriptionConfirmation(this);
+ }
+ }
+}
diff --git a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsUnsubscribeConfirmation.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsUnsubscribeConfirmation.java
new file mode 100644
index 000000000000..3f0dbd7b4584
--- /dev/null
+++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsUnsubscribeConfirmation.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.messagemanager.sns.model;
+
+import java.net.URI;
+import java.util.Objects;
+import software.amazon.awssdk.annotations.SdkPublicApi;
+
+/**
+ * An 'UnsubscribeConfirmation' message from SNS.
+ *
+ * See the API reference for more
+ * information.
+ */
+@SdkPublicApi
+public final class SnsUnsubscribeConfirmation extends SnsMessage {
+ private final URI subscribeUrl;
+ private final String token;
+
+ private SnsUnsubscribeConfirmation(BuilderImpl builder) {
+ super(builder);
+ this.subscribeUrl = builder.subscribeUrl;
+ this.token = builder.token;
+ }
+
+ @Override
+ public SnsMessageType type() {
+ return SnsMessageType.UNSUBSCRIBE_CONFIRMATION;
+ }
+
+ /**
+ * The URL that can be visited used to re-confirm subscription to the topic.
+ */
+ public URI subscribeUrl() {
+ return subscribeUrl;
+ }
+
+ /**
+ * A value that can be used with the
+ * ConfirmSubscription
+ * API to reconfirm subscription to the topic.
+ */
+ public String token() {
+ return token;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ if (!super.equals(o)) {
+ return false;
+ }
+
+ SnsUnsubscribeConfirmation that = (SnsUnsubscribeConfirmation) o;
+ return Objects.equals(subscribeUrl, that.subscribeUrl) && Objects.equals(token, that.token);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = super.hashCode();
+ result = 31 * result + Objects.hashCode(subscribeUrl);
+ result = 31 * result + Objects.hashCode(token);
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ return toStringBuilder("SnsUnsubscribeConfirmation")
+ .add("SubscribeUrl", subscribeUrl())
+ .add("Token", token())
+ .build();
+ }
+
+ public static Builder builder() {
+ return new BuilderImpl();
+ }
+
+ public interface Builder extends SnsMessage.Builder {
+
+ /**
+ * The URL that can be visited used to re-confirm subscription to the topic.
+ */
+ Builder subscribeUrl(URI subscribeUrl);
+
+ /**
+ * A value that can be used with the
+ * ConfirmSubscription
+ * API to reconfirm subscription to the topic.
+ */
+ Builder token(String token);
+
+ SnsUnsubscribeConfirmation build();
+ }
+
+ private static class BuilderImpl extends SnsMessage.BuilderImpl implements Builder {
+ private URI subscribeUrl;
+ private String token;
+
+ @Override
+ public Builder subscribeUrl(URI subscribeUrl) {
+ this.subscribeUrl = subscribeUrl;
+ return this;
+ }
+
+ @Override
+ public Builder token(String token) {
+ this.token = token;
+ return this;
+ }
+
+ @Override
+ public SnsUnsubscribeConfirmation build() {
+ return new SnsUnsubscribeConfirmation(this);
+ }
+ }
+}
diff --git a/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateRetrieverTest.java b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateRetrieverTest.java
new file mode 100644
index 000000000000..bc31f5def15a
--- /dev/null
+++ b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateRetrieverTest.java
@@ -0,0 +1,355 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.messagemanager.sns.internal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.core.exception.SdkClientException;
+import software.amazon.awssdk.http.AbortableInputStream;
+import software.amazon.awssdk.http.ExecutableHttpRequest;
+import software.amazon.awssdk.http.HttpExecuteRequest;
+import software.amazon.awssdk.http.HttpExecuteResponse;
+import software.amazon.awssdk.http.SdkHttpClient;
+import software.amazon.awssdk.http.SdkHttpFullResponse;
+import software.amazon.awssdk.utils.IoUtils;
+
+class CertificateRetrieverTest {
+ private static final String RESOURCE_ROOT = "/software/amazon/awssdk/messagemanager/sns/internal/";
+ private static final String CERT_COMMON_NAME = "my-test-service.amazonaws.com";
+ private static final URI TEST_CERT_URI = URI.create("https://my-test-service.amazonaws.com/cert.pem");
+ private SdkHttpClient mockHttpClient;
+
+ private static byte[] validCert;
+ private static byte[] expiredCert;
+ private static byte[] futureValidCert;
+
+ @BeforeAll
+ static void classSetUp() throws IOException {
+ validCert = getResourceBytes("valid-cert.pem");
+ expiredCert = getResourceBytes("expired-cert.pem");
+ futureValidCert = getResourceBytes("valid-in-future-cert.pem");
+ }
+
+ @BeforeEach
+ void setUp() {
+ mockHttpClient = mock(SdkHttpClient.class);
+ }
+
+ @Test
+ void close_closesHttpClient() {
+ new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), CERT_COMMON_NAME).close();
+ verify(mockHttpClient).close();
+ }
+
+ @Test
+ void constructor_nullHttpClient_throwsException() {
+ assertThatThrownBy(() -> new CertificateRetriever(null, TEST_CERT_URI.getHost(), CERT_COMMON_NAME))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("httpClient must not be null.");
+ }
+
+ @Test
+ void constructor_nullCertCommonName_throwsException() {
+ assertThatThrownBy(() -> new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), (String) null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("certCommonName must not be null.");
+ }
+
+ @Test
+ void constructor_nullCertHost_throwsException() {
+ assertThatThrownBy(() -> new CertificateRetriever(mockHttpClient, null, CERT_COMMON_NAME))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("Expected certificate host cannot be null or empty");
+ }
+
+ @Test
+ void retrieveCertificate_nullUrl_throwsException() {
+ assertThatThrownBy(() -> new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), CERT_COMMON_NAME)
+ .retrieveCertificate(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("certificateUrl must not be null.");
+ }
+
+ @Test
+ void retrieveCertificate_httpUrl_throwsException() {
+ assertThatThrownBy(() -> new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(),CERT_COMMON_NAME)
+ .retrieveCertificate(URI.create("http://my-service.amazonaws.com/cert.pem")))
+ .isInstanceOf(SdkClientException.class)
+ .hasMessageContaining("Certificate URL must use HTTPS");
+ }
+
+ @Test
+ void retrieveCertificate_httpError_throwsException() throws IOException {
+ mockResponse(SdkHttpFullResponse.builder().statusCode(400).build(), null);
+
+ CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(),
+ CERT_COMMON_NAME);
+
+ assertThatThrownBy(() -> certificateRetriever.retrieveCertificate(TEST_CERT_URI))
+ .isInstanceOf(SdkClientException.class)
+ .hasMessageContaining("Request was unsuccessful. Status Code: 400");
+ }
+
+ @Test
+ void retrieveCertificate_callThrows_throwsException() throws IOException {
+ ExecutableHttpRequest mockExecRequest = mock(ExecutableHttpRequest.class);
+
+ RuntimeException cause = new RuntimeException("oops");
+ when(mockExecRequest.call()).thenThrow(cause);
+
+ when(mockHttpClient.prepareRequest(any(HttpExecuteRequest.class))).thenReturn(mockExecRequest);
+
+ CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(),
+ CERT_COMMON_NAME);
+
+ assertThatThrownBy(() -> certificateRetriever
+ .retrieveCertificate(TEST_CERT_URI))
+ .isInstanceOf(SdkClientException.class)
+ .hasCause(cause)
+ .hasMessageContaining("Unexpected error while retrieving URL");
+ }
+
+ @Test
+ void retrieveCertificate_noResponseStream_throwsException() throws IOException {
+ mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), null);
+
+ CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(),
+ CERT_COMMON_NAME);
+
+ assertThatThrownBy(() -> certificateRetriever.retrieveCertificate(TEST_CERT_URI))
+ .isInstanceOf(SdkClientException.class)
+ .hasMessageContaining("Response body is empty");
+ }
+
+ @Test
+ void retrieveCertificate_emptyResponseBody_throwsException() throws IOException {
+ mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), new byte[0]);
+
+ CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(),
+ CERT_COMMON_NAME);
+
+ assertThatThrownBy(() -> certificateRetriever.retrieveCertificate(TEST_CERT_URI))
+ .isInstanceOf(SdkClientException.class)
+ .hasMessageContaining("Certificate does not match expected X509 PEM format.");
+ }
+
+ @Test
+ void retrieveCertificate_invalidCertificateFormat_throwsException() throws IOException {
+ mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), "this is not a cert".getBytes(StandardCharsets.UTF_8));
+
+ CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(),
+ CERT_COMMON_NAME);
+
+ assertThatThrownBy(() -> certificateRetriever.retrieveCertificate(TEST_CERT_URI))
+ .isInstanceOf(SdkClientException.class)
+ .hasMessageContaining("Certificate does not match expected X509 PEM format.");
+ }
+
+ @Test
+ void retrieveCertificate_nonParsableCertificate_throwsException() throws IOException {
+ String certificate = "-----BEGIN CERTIFICATE-----\n"
+ + "MIIE+DCCAuCgAwIBAgIJAOCC5W/Vl4AEMA0GCSqGSIb3DQEBDAUAMCgxJjAkBgNV\n"
+ + "-----END CERTIFICATE-----\n";
+ mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), certificate.getBytes(StandardCharsets.UTF_8));
+
+ CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(),
+ CERT_COMMON_NAME);
+
+ assertThatThrownBy(() -> certificateRetriever.retrieveCertificate(TEST_CERT_URI))
+ .isInstanceOf(SdkClientException.class)
+ .hasMessageContaining("The certificate could not be parsed");
+ }
+
+ @Test
+ void retrieveCertificate_certificateExpired_throwsException() throws IOException {
+ mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), expiredCert);
+ CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(),
+ CERT_COMMON_NAME);
+
+ assertThatThrownBy(() -> certificateRetriever.retrieveCertificate(TEST_CERT_URI))
+ .isInstanceOf(SdkClientException.class)
+ .hasMessageContaining("The certificate is expired");
+ }
+
+ @Test
+ void retrieveCertificate_certNotYetValid_throwsException() throws IOException {
+ mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), futureValidCert);
+ CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(),
+ CERT_COMMON_NAME);
+
+ assertThatThrownBy(() -> certificateRetriever.retrieveCertificate(TEST_CERT_URI))
+ .isInstanceOf(SdkClientException.class)
+ .hasMessageContaining("The certificate is not yet valid");
+ }
+
+ @Test
+ void retrieveCertificate_commonNameMismatch_throwsException() throws IOException {
+ String commonName = "my-other-service.amazonaws.com";
+ URI certUri = URI.create("https://" + commonName + "/cert.pem");
+ mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), futureValidCert);
+ CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, commonName, commonName);
+
+ assertThatThrownBy(() -> certificateRetriever.retrieveCertificate(certUri))
+ .isInstanceOf(SdkClientException.class)
+ .hasMessageContaining("Certificate does not match expected common name: my-other-service.amazonaws.com");
+ }
+
+ @Test
+ void retrieveCertificate_validPemCertificate_succeeds() throws IOException {
+ mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), validCert);
+
+ CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(),
+ CERT_COMMON_NAME);
+
+ assertThat(certificateRetriever.retrieveCertificate(TEST_CERT_URI)).isNotNull();
+ }
+
+ @Test
+ void retrieveCertificate_cacheHit_returnsFromCache() throws IOException {
+ mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), validCert);
+
+ CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(),
+ CERT_COMMON_NAME);
+
+ certificateRetriever.retrieveCertificate(TEST_CERT_URI);
+ certificateRetriever.retrieveCertificate(TEST_CERT_URI);
+
+ verify(mockHttpClient, times(1)).prepareRequest(any(HttpExecuteRequest.class));
+ }
+
+ @Test
+ void retrieveCertificate_differentUrls_cachesIndependently() throws IOException {
+ mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), validCert);
+
+ CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(),
+ CERT_COMMON_NAME);
+
+ URI cert1Url = URI.create("https://" + CERT_COMMON_NAME + "/cert1.pem");
+ certificateRetriever.retrieveCertificate(cert1Url);
+ certificateRetriever.retrieveCertificate(cert1Url);
+
+ URI cert2Url = URI.create("https://" + CERT_COMMON_NAME + "/cert2.pem");
+ certificateRetriever.retrieveCertificate(cert2Url);
+ certificateRetriever.retrieveCertificate(cert2Url);
+
+ verify(mockHttpClient, times(2)).prepareRequest(any(HttpExecuteRequest.class));
+
+ }
+
+ @Test
+ void retrieveCertificate_concurrentAccess_threadSafe() throws Exception {
+ int threads = 4;
+ ExecutorService exec = Executors.newFixedThreadPool(threads);
+
+
+ for (int i = 0; i < 10_000; ++i) {
+ CountDownLatch start = new CountDownLatch(threads);
+ CountDownLatch end = new CountDownLatch(threads);
+
+ mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), validCert);
+
+ CertificateRetriever retriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), CERT_COMMON_NAME);
+ for (int j = 0; j < threads; ++j) {
+ exec.submit(() -> {
+ start.countDown();
+
+ retriever.retrieveCertificate(TEST_CERT_URI);
+
+ end.countDown();
+ });
+ }
+
+ end.await();
+
+ verify(mockHttpClient, times(1)).prepareRequest(any(HttpExecuteRequest.class));
+ reset(mockHttpClient);
+ }
+ }
+
+ @Test
+ void retrieveCertificate_concurrentDifferentUrls_threadSafe() throws Exception {
+ int threads = 4;
+ ExecutorService exec = Executors.newFixedThreadPool(threads);
+
+
+ for (int i = 0; i < 10_000; ++i) {
+ CountDownLatch start = new CountDownLatch(threads);
+ CountDownLatch end = new CountDownLatch(threads);
+
+ mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), validCert);
+
+ CertificateRetriever retriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), CERT_COMMON_NAME);
+ for (int j = 0; j < threads; ++j) {
+ URI uri = URI.create(String.format("https://" + CERT_COMMON_NAME + "/cert%d.pem", j % 2));
+ exec.submit(() -> {
+ start.countDown();
+
+ retriever.retrieveCertificate(uri);
+
+ end.countDown();
+ });
+ }
+
+ end.await();
+
+ verify(mockHttpClient, times(2)).prepareRequest(any(HttpExecuteRequest.class));
+ reset(mockHttpClient);
+ }
+ }
+
+ private static byte[] getResourceBytes(String resourcePath) throws IOException {
+ return IoUtils.toByteArray(CertificateRetrieverTest.class.getResourceAsStream(RESOURCE_ROOT + resourcePath));
+ }
+
+ private void mockResponse(SdkHttpFullResponse httpResponse, byte[] content) throws IOException {
+ ExecutableHttpRequest mockExecRequest = mock(ExecutableHttpRequest.class);
+
+ when(mockExecRequest.call()).thenAnswer(i -> {
+ AbortableInputStream body = null;
+ if (content != null) {
+ body = asStream(content);
+ }
+
+ return HttpExecuteResponse.builder()
+ .response(httpResponse)
+ .responseBody(body)
+ .build();
+ });
+
+ when(mockHttpClient.prepareRequest(any(HttpExecuteRequest.class))).thenReturn(mockExecRequest);
+ }
+
+ private static AbortableInputStream asStream(byte[] b) {
+ return AbortableInputStream.create(new ByteArrayInputStream(b));
+ }
+}
diff --git a/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateUrlValidatorTest.java b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateUrlValidatorTest.java
new file mode 100644
index 000000000000..dba83c8d8e20
--- /dev/null
+++ b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateUrlValidatorTest.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.messagemanager.sns.internal;
+
+import static org.assertj.core.api.Assertions.assertThatNoException;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.net.URI;
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.core.exception.SdkClientException;
+
+public class CertificateUrlValidatorTest {
+ private static final String CERT_HOST = "my-test-service.amazonaws.com";
+
+ @Test
+ void validate_urlValid_succeeds() {
+ CertificateUrlValidator validator = new CertificateUrlValidator(CERT_HOST);
+ assertThatNoException().isThrownBy(() -> validator.validate(URI.create("https://" + CERT_HOST)));
+ }
+
+ @Test
+ void validate_urlNull_throws() {
+ CertificateUrlValidator validator = new CertificateUrlValidator(CERT_HOST);
+ assertThatThrownBy(() -> validator.validate(null))
+ .isInstanceOf(SdkClientException.class)
+ .hasMessage("Certificate URL cannot be null");
+ }
+
+ @Test
+ void validate_schemeNotHttps_throws() {
+ CertificateUrlValidator validator = new CertificateUrlValidator(CERT_HOST);
+ assertThatThrownBy(() -> validator.validate(URI.create("http://" + CERT_HOST)))
+ .isInstanceOf(SdkClientException.class)
+ .hasMessage("Certificate URL must use HTTPS");
+ }
+
+ @Test
+ void validate_urlHostDoesNotMatchExpectedHost_throws() {
+ CertificateUrlValidator validator = new CertificateUrlValidator(CERT_HOST);
+ assertThatThrownBy(() -> validator.validate(URI.create("https://my-other-test-service.amazonaws.com")))
+ .isInstanceOf(SdkClientException.class)
+ .hasMessage("Certificate URL does not match expected host: " + CERT_HOST);
+ }
+
+}
diff --git a/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/DefaultSnsMessageManagerTest.java b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/DefaultSnsMessageManagerTest.java
new file mode 100644
index 000000000000..55f63a5938e0
--- /dev/null
+++ b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/DefaultSnsMessageManagerTest.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.messagemanager.sns.internal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.io.InputStream;
+import java.time.Duration;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import software.amazon.awssdk.core.SdkSystemSetting;
+import software.amazon.awssdk.http.SdkHttpClient;
+import software.amazon.awssdk.http.SdkHttpConfigurationOption;
+import software.amazon.awssdk.http.SdkHttpService;
+import software.amazon.awssdk.messagemanager.sns.SnsMessageManager;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.utils.AttributeMap;
+
+public class DefaultSnsMessageManagerTest {
+ private static SdkHttpClient.Builder> mockHttpClientBuilder;
+ private static SdkHttpClient mockHttpClient;
+
+ @BeforeAll
+ static void setup() {
+ mockHttpClientBuilder = mock(SdkHttpClient.Builder.class);
+ mockHttpClient = mock(SdkHttpClient.class);
+ when(mockHttpClientBuilder.buildWithDefaults(any(AttributeMap.class))).thenReturn(mockHttpClient);
+ }
+
+ @Test
+ void close_httpClientConfiguredOnBuilder_httpClientNotClosed() {
+ SdkHttpClient mockClient = mock(SdkHttpClient.class);
+
+ SnsMessageManager msgManager = DefaultSnsMessageManager.builder()
+ .httpClient(mockClient)
+ .region(Region.US_WEST_2)
+ .build();
+
+ msgManager.close();
+
+ verifyNoInteractions(mockClient);
+ }
+
+ @Test
+ void parseMessage_streamNull_throws() {
+ SnsMessageManager msgManager =
+ DefaultSnsMessageManager.builder().httpClient(mockHttpClient).region(Region.US_WEST_2).build();
+
+ assertThatThrownBy(() -> msgManager.parseMessage((InputStream) null))
+ .hasMessage("message cannot be null");
+ }
+
+ @Test
+ void parseMessage_stringNull_throws() {
+ SnsMessageManager msgManager =
+ DefaultSnsMessageManager.builder().httpClient(mockHttpClient).region(Region.US_WEST_2).build();
+
+ assertThatThrownBy(() -> msgManager.parseMessage((String) null))
+ .hasMessage("message cannot be null");
+ }
+
+ @Test
+ void close_certRetrieverClosed() {
+ CertificateRetriever mockCertRetriever = mock(CertificateRetriever.class);
+
+ SnsMessageManager msgManager = new DefaultSnsMessageManager.BuilderImpl()
+ .certificateRetriever(mockCertRetriever)
+ .region(Region.US_WEST_2)
+ .build();
+
+ msgManager.close();
+
+ verify(mockCertRetriever).close();
+ }
+
+ @Test
+ void build_httpClientNotConfigured_usesDefaultHttpClient() {
+ System.setProperty(SdkSystemSetting.SYNC_HTTP_SERVICE_IMPL.property(), TestHttpService.class.getName());
+ try {
+ DefaultSnsMessageManager.builder().region(Region.US_WEST_2).build();
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(AttributeMap.class);
+ verify(mockHttpClientBuilder).buildWithDefaults(captor.capture());
+
+ AttributeMap buildArgs = captor.getValue();
+ assertThat(buildArgs.get(SdkHttpConfigurationOption.CONNECTION_TIMEOUT)).isEqualTo(Duration.ofSeconds(10));
+ assertThat(buildArgs.get(SdkHttpConfigurationOption.READ_TIMEOUT)).isEqualTo(Duration.ofSeconds(30));
+ } finally {
+ System.clearProperty(SdkSystemSetting.SYNC_HTTP_SERVICE_IMPL.property());
+ }
+ }
+
+ @Test
+ void close_defaultHttpClientUsed_isClosed() {
+ System.setProperty(SdkSystemSetting.SYNC_HTTP_SERVICE_IMPL.property(), TestHttpService.class.getName());
+ try {
+ SnsMessageManager msgManager = DefaultSnsMessageManager.builder()
+ .region(Region.US_WEST_2)
+ .build();
+ msgManager.close();
+
+ verify(mockHttpClient).close();
+ } finally {
+ System.clearProperty(SdkSystemSetting.SYNC_HTTP_SERVICE_IMPL.property());
+ }
+ }
+
+ // NOTE: needs to be public to work with the service loader
+ public static class TestHttpService implements SdkHttpService {
+
+ @Override
+ public SdkHttpClient.Builder> createHttpClientBuilder() {
+ return mockHttpClientBuilder;
+ }
+ }
+}
diff --git a/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/SignatureValidatorTest.java b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/SignatureValidatorTest.java
new file mode 100644
index 000000000000..fe30a03d16a1
--- /dev/null
+++ b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/SignatureValidatorTest.java
@@ -0,0 +1,199 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.messagemanager.sns.internal;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.InputStream;
+import java.net.URI;
+import java.security.PublicKey;
+import java.security.cert.CertificateException;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.time.Instant;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import software.amazon.awssdk.core.SdkBytes;
+import software.amazon.awssdk.core.exception.SdkClientException;
+import software.amazon.awssdk.messagemanager.sns.model.SignatureVersion;
+import software.amazon.awssdk.messagemanager.sns.model.SnsMessage;
+import software.amazon.awssdk.messagemanager.sns.model.SnsNotification;
+
+class SignatureValidatorTest {
+ private static final String RESOURCE_ROOT = "/software/amazon/awssdk/messagemanager/sns/internal/";
+ private static final String SIGNING_CERT_RESOURCE = "SimpleNotificationService-7506a1e35b36ef5a444dd1a8e7cc3ed8.pem";
+ private static final SignatureValidator VALIDATOR = new SignatureValidator();
+ private static X509Certificate signingCertificate;
+
+ @BeforeAll
+ static void setup() throws CertificateException {
+ InputStream is = resourceAsStream(SIGNING_CERT_RESOURCE);
+ CertificateFactory factory = CertificateFactory.getInstance("X.509");
+ signingCertificate = (X509Certificate) factory.generateCertificate(is);
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("validMessages")
+ void validateSignature_signatureValid_doesNotThrow(TestCase tc) {
+ SnsMessageUnmarshaller unmarshaller = new SnsMessageUnmarshaller();
+ SnsMessage msg = unmarshaller.unmarshall(resourceAsStream(tc.messageJsonResource));
+ VALIDATOR.validateSignature(msg, signingCertificate.getPublicKey());
+ }
+
+ @Test
+ void validateSignature_signatureMismatch_throws() {
+ SnsNotification notification = SnsNotification.builder()
+ .message("hello world")
+ .messageId("message-id")
+ .timestamp(Instant.now())
+ .signature(SdkBytes.fromByteArray(new byte[256]))
+ .signatureVersion(SignatureVersion.VERSION_1)
+ .build();
+
+ assertThatThrownBy(() -> VALIDATOR.validateSignature(notification, signingCertificate.getPublicKey()))
+ .isInstanceOf(SdkClientException.class)
+ .hasMessageContaining("The computed signature did not match the expected signature");
+ }
+
+ @Test
+ void validateSignature_signatureMissing_throws() {
+ SnsNotification notification = SnsNotification.builder()
+ .subject("hello world")
+ .message("hello world")
+ .messageId("message-id")
+ .timestamp(Instant.now())
+ .unsubscribeUrl(URI.create("https://my-test-service.amazonaws.com"))
+ .signingCertUrl(URI.create("https://my-test-service.amazonaws.com/cert"
+ + ".pem"))
+ .signatureVersion(SignatureVersion.VERSION_1)
+ .build();
+
+ assertThatThrownBy(() -> VALIDATOR.validateSignature(notification, signingCertificate.getPublicKey()))
+ .isInstanceOf(SdkClientException.class)
+ .hasMessage("Message signature cannot be null");
+ }
+
+ @Test
+ void validateSignature_timestampMissing_throws() {
+ SnsNotification notification = SnsNotification.builder()
+ .subject("hello world")
+ .message("hello world")
+ .messageId("message-id")
+ .signature(SdkBytes.fromByteArray(new byte[256]))
+ .unsubscribeUrl(URI.create("https://my-test-service.amazonaws.com"))
+ .signingCertUrl(URI.create("https://my-test-service.amazonaws.com/cert"
+ + ".pem"))
+ .signatureVersion(SignatureVersion.VERSION_1)
+ .build();
+
+ assertThatThrownBy(() -> VALIDATOR.validateSignature(notification, signingCertificate.getPublicKey()))
+ .isInstanceOf(SdkClientException.class)
+ .hasMessage("Message timestamp cannot be null");
+ }
+
+ @Test
+ void validateSignature_signatureVersionMissing_throws() {
+ SnsNotification notification = SnsNotification.builder()
+ .subject("hello world")
+ .message("hello world")
+ .messageId("message-id")
+ .signature(SdkBytes.fromByteArray(new byte[256]))
+ .timestamp(Instant.now())
+ .unsubscribeUrl(URI.create("https://my-test-service.amazonaws.com"))
+ .signingCertUrl(URI.create("https://my-test-service.amazonaws.com/cert"
+ + ".pem"))
+ .build();
+
+
+ assertThatThrownBy(() -> VALIDATOR.validateSignature(notification, signingCertificate.getPublicKey()))
+ .isInstanceOf(SdkClientException.class)
+ .hasMessage("Message signature version cannot be null");
+ }
+
+ @Test
+ void validateSignature_certInvalid_throws() throws CertificateException {
+ SnsNotification notification = SnsNotification.builder()
+ .signature(SdkBytes.fromByteArray(new byte[1]))
+ .signatureVersion(SignatureVersion.VERSION_1)
+ .timestamp(Instant.now())
+ .build();
+
+ PublicKey badKey = mock(PublicKey.class);
+ when(badKey.getFormat()).thenReturn("X.509");
+ when(badKey.getAlgorithm()).thenReturn("RSA");
+ when(badKey.getEncoded()).thenReturn(new byte[1]);
+
+ assertThatThrownBy(() -> VALIDATOR.validateSignature(notification, badKey))
+ .isInstanceOf(SdkClientException.class)
+ .hasMessage("The public key is invalid");
+ }
+
+ @Test
+ void validateSignature_signatureInvalid_throws() throws CertificateException {
+ SnsNotification notification = SnsNotification.builder()
+ .subject("hello world")
+ .message("hello world")
+ .messageId("message-id")
+ .signature(SdkBytes.fromByteArray(new byte[1]))
+ .signatureVersion(SignatureVersion.VERSION_1)
+ .timestamp(Instant.now())
+ .unsubscribeUrl(URI.create("https://my-test-service.amazonaws.com"))
+ .signingCertUrl(URI.create("https://my-test-service.amazonaws.com/cert"
+ + ".pem"))
+ .build();
+
+ assertThatThrownBy(() -> VALIDATOR.validateSignature(notification, signingCertificate.getPublicKey()))
+ .isInstanceOf(SdkClientException.class)
+ .hasMessage("The signature is invalid");
+ }
+
+ private static List validMessages() {
+ return Stream.of(
+ new TestCase("Notification - No Subject", "test-notification-no-subject.json"),
+ new TestCase("Notification - Version 2 signature", "test-notification-signature-v2.json"),
+ new TestCase("Notification with subject", "test-notification-with-subject.json"),
+ new TestCase("Subscription confirmation", "test-subscription-confirmation.json"),
+ new TestCase("Unsubscribe confirmation", "test-unsubscribe-confirmation.json")
+ )
+ .collect(Collectors.toList());
+ }
+
+ private static InputStream resourceAsStream(String resourceName) {
+ return SignatureValidatorTest.class.getResourceAsStream(RESOURCE_ROOT + resourceName);
+ }
+
+ private static class TestCase {
+ private String desription;
+ private String messageJsonResource;
+
+ public TestCase(String desription, String messageJsonResource) {
+ this.desription = desription;
+ this.messageJsonResource = messageJsonResource;
+ }
+
+ @Override
+ public String toString() {
+ return desription;
+ }
+ }
+}
\ No newline at end of file
diff --git a/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/SnsHostProviderTest.java b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/SnsHostProviderTest.java
new file mode 100644
index 000000000000..95590e0fecaa
--- /dev/null
+++ b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/SnsHostProviderTest.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.messagemanager.sns.internal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.ArgumentCaptor;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.sns.endpoints.SnsEndpointParams;
+import software.amazon.awssdk.services.sns.endpoints.SnsEndpointProvider;
+
+public class SnsHostProviderTest {
+
+ @ParameterizedTest
+ @MethodSource("commonNameTestCases")
+ void signingCertCommonName_returnsCorrectNameForRegion(CommonNameTestCase tc) {
+ SnsHostProvider hostProvider = new SnsHostProvider(Region.of(tc.region));
+ assertThat(hostProvider.signingCertCommonName()).isEqualTo(tc.expectedCommonName);
+ }
+
+ @Test
+ void ctor_regionNull_throws() {
+ assertThatThrownBy(() -> new SnsHostProvider(null)).hasMessage("region must not be null");
+ }
+
+ @Test
+ void ctor_endpointProviderNull_throws() {
+ assertThatThrownBy(() -> new SnsHostProvider(Region.US_WEST_2, null))
+ .hasMessage("endpointProvider must not be null");
+ }
+
+ @Test
+ void regionalEndpoint_delegatesToEndpointProvider() {
+ SnsEndpointProvider mockProvider = mock(SnsEndpointProvider.class);
+ SnsEndpointProvider realProvider = SnsEndpointProvider.defaultProvider();
+
+ when(mockProvider.resolveEndpoint(any(SnsEndpointParams.class))).thenAnswer(
+ i -> realProvider.resolveEndpoint(i.getArgument(0, SnsEndpointParams.class)));
+
+ ArgumentCaptor paramsCaptor = ArgumentCaptor.forClass(SnsEndpointParams.class);
+
+ Region region = Region.US_WEST_2;
+ SnsHostProvider hostProvider = new SnsHostProvider(region, mockProvider);
+ hostProvider.regionalEndpoint();
+
+ verify(mockProvider).resolveEndpoint(paramsCaptor.capture());
+ assertThat(paramsCaptor.getValue().region()).isEqualTo(region);
+ }
+
+ private static Stream commonNameTestCases() {
+ return Stream.of(
+ // gov regions
+ new CommonNameTestCase("us-gov-west-1", "sns-us-gov-west-1.amazonaws.com"),
+ new CommonNameTestCase("us-gov-east-1", "sns-us-gov-west-1.amazonaws.com"),
+
+ // cn regions
+ new CommonNameTestCase("cn-north-1", "sns-cn-north-1.amazonaws.com.cn"),
+ new CommonNameTestCase("cn-northwest-1", "sns-cn-northwest-1.amazonaws.com.cn"),
+
+ // opt-in regions
+ new CommonNameTestCase("me-south-1", "sns-signing.me-south-1.amazonaws.com"),
+ new CommonNameTestCase("ap-east-1", "sns-signing.ap-east-1.amazonaws.com"),
+ new CommonNameTestCase("me-south-1", "sns-signing.me-south-1.amazonaws.com"),
+ new CommonNameTestCase("ap-east-2", "sns-signing.ap-east-2.amazonaws.com"),
+ new CommonNameTestCase("ap-southeast-5", "sns-signing.ap-southeast-5.amazonaws.com"),
+ new CommonNameTestCase("ap-southeast-6", "sns-signing.ap-southeast-6.amazonaws.com"),
+ new CommonNameTestCase("ap-southeast-7", "sns-signing.ap-southeast-7.amazonaws.com"),
+ new CommonNameTestCase("mx-central-1", "sns-signing.mx-central-1.amazonaws.com"),
+
+ // iso regions
+ new CommonNameTestCase("us-iso-east-1", "sns-us-iso-east-1.c2s.ic.gov"),
+ new CommonNameTestCase("us-isob-east-1", "sns-us-isob-east-1.sc2s.sgov.gov"),
+ new CommonNameTestCase("us-isof-east-1", "sns-signing.us-isof-east-1.csp.hci.ic.gov"),
+ new CommonNameTestCase("us-isof-south-1", "sns-signing.us-isof-south-1.csp.hci.ic.gov"),
+ new CommonNameTestCase("eu-isoe-west-1", "sns-signing.eu-isoe-west-1.cloud.adc-e.uk"),
+
+ //eusc
+ new CommonNameTestCase("eusc-de-east-1", "sns-signing.eusc-de-east-1.amazonaws.eu"),
+
+ // other regions
+ new CommonNameTestCase("us-east-1", "sns.amazonaws.com"),
+ new CommonNameTestCase("us-west-1", "sns.amazonaws.com"),
+
+ // unknown regions
+ new CommonNameTestCase("us-east-9", "sns.amazonaws.com"),
+ new CommonNameTestCase("foo-bar-1", "sns.amazonaws.com"),
+ new CommonNameTestCase("cn-northwest-9", "sns.amazonaws.com.cn")
+
+
+ );
+ }
+
+ private static class CommonNameTestCase {
+ private String region;
+ private String expectedCommonName;
+
+ CommonNameTestCase(String region, String expectedCommonName) {
+ this.region = region;
+ this.expectedCommonName = expectedCommonName;
+ }
+
+ @Override
+ public String toString() {
+ return region + " - " + expectedCommonName;
+ }
+ }
+}
diff --git a/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/SnsMessageUnmarshallerTest.java b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/SnsMessageUnmarshallerTest.java
new file mode 100644
index 000000000000..67722ee592d9
--- /dev/null
+++ b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/SnsMessageUnmarshallerTest.java
@@ -0,0 +1,221 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.messagemanager.sns.internal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.core.exception.SdkClientException;
+import software.amazon.awssdk.messagemanager.sns.model.SignatureVersion;
+import software.amazon.awssdk.messagemanager.sns.model.SnsMessage;
+import software.amazon.awssdk.messagemanager.sns.model.SnsMessageType;
+import software.amazon.awssdk.messagemanager.sns.model.SnsNotification;
+import software.amazon.awssdk.messagemanager.sns.model.SnsSubscriptionConfirmation;
+import software.amazon.awssdk.messagemanager.sns.model.SnsUnsubscribeConfirmation;
+import software.amazon.awssdk.utils.BinaryUtils;
+
+public class SnsMessageUnmarshallerTest {
+ private static final SnsMessageUnmarshaller UNMARSHALLER = new SnsMessageUnmarshaller();
+ private static final byte[] TEST_SIGNATURE = BinaryUtils.fromBase64("k764G/ur2Ng=");
+ private static final SignatureVersion TEST_SIGNATURE_VERSION = SignatureVersion.VERSION_1;
+ private static final String TEST_TOPIC_ARN = "arn:aws:sns:us-west-2:123456789012:MyTopic";
+ private static final String TEST_MESSAGE_ID = "22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324";
+ private static final String TEST_MESSAGE = "Hello world!";
+ private static final String TEST_SUBJECT = "My First Message";
+ private static final URI TEST_SIGNING_CERT_URL = URI.create("https://sns.us-west-2.amazonaws.com/SimpleNotificationService-f3ecfb7224c7233fe7bb5f59f96de52f.pem");
+ private static final URI TEST_SUBSCRIBE_URL = URI.create("https://sns.us-west-2.amazonaws.com/?Action=ConfirmSubscription&TopicArn=arn:aws:sns:us-west-2:123456789012:MyTopic&Token=token");
+ private static final URI TEST_UNSUBSCRIBE_URL = URI.create("https://sns.us-west-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-west-2:123456789012:MyTopic:c9135db0-26c4-47ec-8998-413945fb5a96");
+ private static final Instant TEST_TIMESTAMP = Instant.parse("2012-05-02T00:54:06.655Z");
+ private static final String TEST_TOKEN = "token";
+
+ @Test
+ void unmarshall_notification_maximal() {
+ String json = "{\n"
+ + " \"Type\" : \"Notification\",\n"
+ + " \"MessageId\" : \"22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324\",\n"
+ + " \"TopicArn\" : \"arn:aws:sns:us-west-2:123456789012:MyTopic\",\n"
+ + " \"Subject\" : \"My First Message\",\n"
+ + " \"Message\" : \"Hello world!\",\n"
+ + " \"Timestamp\" : \"2012-05-02T00:54:06.655Z\",\n"
+ + " \"SignatureVersion\" : \"1\",\n"
+ + " \"Signature\" : \"k764G/ur2Ng=\",\n"
+ + " \"SigningCertURL\" : \"https://sns.us-west-2.amazonaws.com/SimpleNotificationService-f3ecfb7224c7233fe7bb5f59f96de52f.pem\",\n"
+ + " \"UnsubscribeURL\" : \"https://sns.us-west-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-west-2:123456789012:MyTopic:c9135db0-26c4-47ec-8998-413945fb5a96\"\n"
+ + "}";
+
+ SnsMessage msg = UNMARSHALLER.unmarshall(asStream(json));
+
+ assertThat(msg).isInstanceOf(SnsNotification.class);
+
+ SnsNotification notification = (SnsNotification) msg;
+
+ assertThat(notification.type()).isEqualTo(SnsMessageType.NOTIFICATION);
+ assertThat(notification.messageId()).isEqualTo(TEST_MESSAGE_ID);
+ assertThat(notification.message()).isEqualTo(TEST_MESSAGE);
+ assertThat(notification.topicArn()).isEqualTo(TEST_TOPIC_ARN);
+ assertThat(notification.signature().asByteArray()).isEqualTo(TEST_SIGNATURE);
+ assertThat(notification.signatureVersion()).isEqualTo(TEST_SIGNATURE_VERSION);
+ assertThat(notification.subject()).isEqualTo(TEST_SUBJECT);
+ assertThat(notification.timestamp()).isEqualTo(TEST_TIMESTAMP);
+ assertThat(notification.signingCertUrl()).isEqualTo(TEST_SIGNING_CERT_URL);
+ assertThat(notification.unsubscribeUrl()).isEqualTo(TEST_UNSUBSCRIBE_URL);
+ }
+
+ @Test
+ void unmarshall_subscriptionNotification_maximal() {
+ String json = "{\n"
+ + " \"Type\" : \"SubscriptionConfirmation\",\n"
+ + " \"MessageId\" : \"22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324\",\n"
+ + " \"Token\" : \"token\",\n"
+ + " \"TopicArn\" : \"arn:aws:sns:us-west-2:123456789012:MyTopic\",\n"
+ + " \"Message\" : \"Hello world!\",\n"
+ + " \"SubscribeURL\" : \"https://sns.us-west-2.amazonaws.com/?Action=ConfirmSubscription&TopicArn=arn:aws:sns:us-west-2:123456789012:MyTopic&Token=token\",\n"
+ + " \"Timestamp\" : \"2012-05-02T00:54:06.655Z\",\n"
+ + " \"SignatureVersion\" : \"1\",\n"
+ + " \"Signature\" : \"k764G/ur2Ng=\",\n"
+ + " \"SigningCertURL\" : \"https://sns.us-west-2.amazonaws.com/SimpleNotificationService-f3ecfb7224c7233fe7bb5f59f96de52f.pem\"\n"
+ + "}";
+
+ SnsMessage msg = UNMARSHALLER.unmarshall(asStream(json));
+
+ assertThat(msg).isInstanceOf(SnsSubscriptionConfirmation.class);
+
+ SnsSubscriptionConfirmation subscriptionConfirmation = (SnsSubscriptionConfirmation) msg;
+
+ assertThat(subscriptionConfirmation.type()).isEqualTo(SnsMessageType.SUBSCRIPTION_CONFIRMATION);
+ assertThat(subscriptionConfirmation.messageId()).isEqualTo(TEST_MESSAGE_ID);
+ assertThat(subscriptionConfirmation.token()).isEqualTo(TEST_TOKEN);
+ assertThat(subscriptionConfirmation.topicArn()).isEqualTo(TEST_TOPIC_ARN);
+ assertThat(subscriptionConfirmation.message()).isEqualTo(TEST_MESSAGE);
+ assertThat(subscriptionConfirmation.subscribeUrl()).isEqualTo(TEST_SUBSCRIBE_URL);
+ assertThat(subscriptionConfirmation.timestamp()).isEqualTo(TEST_TIMESTAMP);
+ assertThat(subscriptionConfirmation.signatureVersion()).isEqualTo(TEST_SIGNATURE_VERSION);
+ assertThat(subscriptionConfirmation.signature().asByteArray()).isEqualTo(TEST_SIGNATURE);
+ assertThat(subscriptionConfirmation.signingCertUrl()).isEqualTo(TEST_SIGNING_CERT_URL);
+ }
+
+ @Test
+ void unmarshall_unsubscribeConfirmation_maximal() {
+ String json = "{\n"
+ + " \"Type\" : \"UnsubscribeConfirmation\",\n"
+ + " \"MessageId\" : \"22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324\",\n"
+ + " \"Token\" : \"token\",\n"
+ + " \"TopicArn\" : \"arn:aws:sns:us-west-2:123456789012:MyTopic\",\n"
+ + " \"Message\" : \"Hello world!\",\n"
+ + " \"SubscribeURL\" : \"https://sns.us-west-2.amazonaws.com/?Action=ConfirmSubscription&TopicArn=arn:aws:sns:us-west-2:123456789012:MyTopic&Token=token\",\n"
+ + " \"Timestamp\" : \"2012-05-02T00:54:06.655Z\",\n"
+ + " \"SignatureVersion\" : \"1\",\n"
+ + " \"Signature\" : \"k764G/ur2Ng=\",\n"
+ + " \"SigningCertURL\" : \"https://sns.us-west-2.amazonaws.com/SimpleNotificationService-f3ecfb7224c7233fe7bb5f59f96de52f.pem\"\n"
+ + "}";
+
+ SnsMessage msg = UNMARSHALLER.unmarshall(asStream(json));
+
+ assertThat(msg).isInstanceOf(SnsUnsubscribeConfirmation.class);
+
+ SnsUnsubscribeConfirmation unsubscribeConfirmation = (SnsUnsubscribeConfirmation) msg;
+
+ assertThat(unsubscribeConfirmation.type()).isEqualTo(SnsMessageType.UNSUBSCRIBE_CONFIRMATION);
+ assertThat(unsubscribeConfirmation.messageId()).isEqualTo(TEST_MESSAGE_ID);
+ assertThat(unsubscribeConfirmation.token()).isEqualTo(TEST_TOKEN);
+ assertThat(unsubscribeConfirmation.topicArn()).isEqualTo(TEST_TOPIC_ARN);
+ assertThat(unsubscribeConfirmation.message()).isEqualTo(TEST_MESSAGE);
+ assertThat(unsubscribeConfirmation.subscribeUrl()).isEqualTo(TEST_SUBSCRIBE_URL);
+ assertThat(unsubscribeConfirmation.timestamp()).isEqualTo(TEST_TIMESTAMP);
+ assertThat(unsubscribeConfirmation.signatureVersion()).isEqualTo(TEST_SIGNATURE_VERSION);
+ assertThat(unsubscribeConfirmation.signature().asByteArray()).isEqualTo(TEST_SIGNATURE);
+ assertThat(unsubscribeConfirmation.signingCertUrl()).isEqualTo(TEST_SIGNING_CERT_URL);
+ }
+
+ @Test
+ void unmarshall_unknownType_throws() {
+ String json = "{\n"
+ + " \"Type\" : \"MyCustomType\"\n"
+ + "}";
+
+ assertThatThrownBy(() -> UNMARSHALLER.unmarshall(asStream(json)))
+ .isInstanceOf(SdkClientException.class)
+ .hasMessageContaining("Unsupported sns message type");
+ }
+
+ @Test
+ void unmarshall_jsonNotObject_throws() {
+ String json = "[]";
+
+ assertThatThrownBy(() -> UNMARSHALLER.unmarshall(asStream(json)))
+ .isInstanceOf(SdkClientException.class)
+ .hasMessageContaining("Expected an JSON object");
+ }
+
+ @Test
+ void unmarshall_signatureNotDecodable_throws() {
+ String json = "{\n"
+ + " \"Type\" : \"SubscriptionConfirmation\",\n"
+ + " \"Signature\" : \"this is not base64\"\n"
+ + "}";
+
+ assertThatThrownBy(() -> UNMARSHALLER.unmarshall(asStream(json)))
+ .isInstanceOf(SdkClientException.class)
+ .hasMessageContaining("Unable to decode signature");
+ }
+
+ @Test
+ void unmarshall_timestampNotParsable_throws() {
+ String json = "{\n"
+ + " \"Type\" : \"SubscriptionConfirmation\",\n"
+ + " \"MessageId\" : \"165545c9-2a5c-472c-8df2-7ff2be2b3b1b\",\n"
+ + " \"Timestamp\" : \"right now\"\n"
+ + "}";
+
+ assertThatThrownBy(() -> UNMARSHALLER.unmarshall(asStream(json)))
+ .isInstanceOf(SdkClientException.class)
+ .hasMessageContaining("Unable to parse timestamp");
+ }
+
+ @Test
+ void unmarshall_uriNotParsable_throws() {
+ String json = "{\n"
+ + " \"Type\" : \"SubscriptionConfirmation\",\n"
+ + " \"SigningCertURL\" : \"https:// not a uri\"\n"
+ + "}";
+
+ assertThatThrownBy(() -> UNMARSHALLER.unmarshall(asStream(json)))
+ .isInstanceOf(SdkClientException.class)
+ .hasMessageContaining("Unable to parse URI");
+ }
+
+ @Test
+ void unmarshall_valueNotString_throws() {
+ String json = "{\n"
+ + " \"Type\" : [] \n"
+ + "}";
+
+ assertThatThrownBy(() -> UNMARSHALLER.unmarshall(asStream(json)))
+ .isInstanceOf(SdkClientException.class)
+ .hasMessageContaining("Expected field 'Type' to be a string");
+ }
+
+ private static InputStream asStream(String s) {
+ return new ByteArrayInputStream(s.getBytes(StandardCharsets.UTF_8));
+ }
+
+}
diff --git a/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/UnmanagedSdkHttpClientTest.java b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/UnmanagedSdkHttpClientTest.java
new file mode 100644
index 000000000000..36b6471be1b8
--- /dev/null
+++ b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/UnmanagedSdkHttpClientTest.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.messagemanager.sns.internal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import software.amazon.awssdk.http.HttpExecuteRequest;
+import software.amazon.awssdk.http.SdkHttpClient;
+
+public class UnmanagedSdkHttpClientTest {
+ private SdkHttpClient mockHttpClient;
+
+ @BeforeEach
+ void setup() {
+ mockHttpClient = mock(SdkHttpClient.class);
+ }
+
+ @Test
+ void close_doesNotCloseDelegate() {
+ UnmanagedSdkHttpClient unmanaged = new UnmanagedSdkHttpClient(mockHttpClient);
+ unmanaged.close();
+ verifyNoInteractions(mockHttpClient);
+ }
+
+ @Test
+ void prepareRequest_delegatesToRealClient() {
+ UnmanagedSdkHttpClient unmanaged = new UnmanagedSdkHttpClient(mockHttpClient);
+ HttpExecuteRequest request = HttpExecuteRequest.builder().build();
+
+ unmanaged.prepareRequest(request);
+
+ ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpExecuteRequest.class);
+
+ verify(mockHttpClient).prepareRequest(requestCaptor.capture());
+ assertThat(requestCaptor.getValue()).isSameAs(request);
+ }
+}
diff --git a/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/model/SnsNotificationTest.java b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/model/SnsNotificationTest.java
new file mode 100644
index 000000000000..c32de65a0f3e
--- /dev/null
+++ b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/model/SnsNotificationTest.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.messagemanager.sns.model;
+
+import nl.jqno.equalsverifier.EqualsVerifier;
+import org.junit.jupiter.api.Test;
+
+public class SnsNotificationTest {
+ @Test
+ void equalsHashCode_behave() {
+ EqualsVerifier.forClass(SnsNotification.class)
+ .usingGetClass()
+ .verify();
+ }
+}
diff --git a/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/model/SnsSubscriptionConfirmationTest.java b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/model/SnsSubscriptionConfirmationTest.java
new file mode 100644
index 000000000000..369c1e41876d
--- /dev/null
+++ b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/model/SnsSubscriptionConfirmationTest.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.messagemanager.sns.model;
+
+import nl.jqno.equalsverifier.EqualsVerifier;
+import org.junit.jupiter.api.Test;
+
+public class SnsSubscriptionConfirmationTest {
+ @Test
+ void equalsHashCode_behave() {
+ EqualsVerifier.forClass(SnsSubscriptionConfirmation.class)
+ .usingGetClass()
+ .verify();
+ }
+}
diff --git a/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/model/SnsUnsubscribeConfirmationTest.java b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/model/SnsUnsubscribeConfirmationTest.java
new file mode 100644
index 000000000000..db116f542954
--- /dev/null
+++ b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/model/SnsUnsubscribeConfirmationTest.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.messagemanager.sns.model;
+
+import nl.jqno.equalsverifier.EqualsVerifier;
+import org.junit.jupiter.api.Test;
+
+public class SnsUnsubscribeConfirmationTest {
+ @Test
+ void equalsHashCode_behave() {
+ EqualsVerifier.forClass(SnsUnsubscribeConfirmation.class)
+ .usingGetClass()
+ .verify();
+ }
+}
diff --git a/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/SimpleNotificationService-7506a1e35b36ef5a444dd1a8e7cc3ed8.pem b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/SimpleNotificationService-7506a1e35b36ef5a444dd1a8e7cc3ed8.pem
new file mode 100644
index 000000000000..f5f615db34a5
--- /dev/null
+++ b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/SimpleNotificationService-7506a1e35b36ef5a444dd1a8e7cc3ed8.pem
@@ -0,0 +1,33 @@
+-----BEGIN CERTIFICATE-----
+MIIFxTCCBK2gAwIBAgIQBkyRNHAyygLkObkxH5ju+zANBgkqhkiG9w0BAQsFADA8
+MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRwwGgYDVQQDExNBbWF6b24g
+UlNBIDIwNDggTTAxMB4XDTI1MTExMDAwMDAwMFoXDTI2MTAxNDIzNTk1OVowHDEa
+MBgGA1UEAxMRc25zLmFtYXpvbmF3cy5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IB
+DwAwggEKAoIBAQChbVNQ+DEaT5WN+l+bg0iEeF666Z6LDFc1KJr4lNl0odcXQSsk
+KsZYCMVdp4I/Ikk9zWyckRTMkmQGYimpD+bTsMCP5C/sJIU0E9Y2OXjjl5Videkq
+qnRL9VdE5N41Rvks7S/5MpYcSmCsmaHfwcv82Gvx2zm9CrA5Xu7K2ZqhzRQPCRSP
+mHTHQr4ED63ujE+qTpPgQZ0TvgmcrAc08Z75Z7Zzs17aTNc3YhAtQx9WowN8uiHk
+kgc+ONNdtwKvWZNmnbmxMHYtYc291WCP1K1Nh4TV5XNyjIebzQKDrqTCN0ItAr1x
+e6Bl3BYS3SG9XHAy0q3ekITzQsKypcuaK6dTAgMBAAGjggLhMIIC3TAfBgNVHSME
+GDAWgBSBuA5jiokSGOX6OztQlZ/m5ZAThTAdBgNVHQ4EFgQUBQEUzzzuVJAKzAlR
+d0Q//W6HvZUwHAYDVR0RBBUwE4IRc25zLmFtYXpvbmF3cy5jb20wEwYDVR0gBAww
+CjAIBgZngQwBAgEwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMB
+MDsGA1UdHwQ0MDIwMKAuoCyGKmh0dHA6Ly9jcmwucjJtMDEuYW1hem9udHJ1c3Qu
+Y29tL3IybTAxLmNybDB1BggrBgEFBQcBAQRpMGcwLQYIKwYBBQUHMAGGIWh0dHA6
+Ly9vY3NwLnIybTAxLmFtYXpvbnRydXN0LmNvbTA2BggrBgEFBQcwAoYqaHR0cDov
+L2NydC5yMm0wMS5hbWF6b250cnVzdC5jb20vcjJtMDEuY2VyMAwGA1UdEwEB/wQC
+MAAwggF/BgorBgEEAdZ5AgQCBIIBbwSCAWsBaQB3ANdtfRDRp/V3wsfpX9cAv/mC
+yTNaZeHQswFzF8DIxWl3AAABmnAb33MAAAQDAEgwRgIhANXAgMEZWU326cqxqwnf
+ntD0XcBjRbmvVMOp3hZ8boPXAiEAkI3NTMXcdLCChHak1ipOae7K7r3spMGUMk6Q
+cEUKqBEAdgDCMX5XRRmjRe5/ON6ykEHrx8IhWiK/f9W1rXaa2Q5SzQAAAZpwG9+u
+AAAEAwBHMEUCIQD+Qyok8NYaFuQkccW+XIMmxHxj6Yka2wjtg6UN/tbU5wIgcp4z
+8MyC/dUfeLRuHAt0owtVgEu7xXqrMBOYlbXrfC4AdgDLOPcViXyEoURfW8Hd+8lu
+8ppZzUcKaQWFsMsUwxRY5wAAAZpwG9+yAAAEAwBHMEUCIQCmu1jQUG9A2br7OqwM
+FvQXtBX10yIvvDa6a2Ov1Zgg6gIgKlqRvN6WhX0pSHAN2bKmjRwIUI3X9kjS/A/m
+DYULaV4wDQYJKoZIhvcNAQELBQADggEBAIbTQWQhqrG6SzwLq79tKCpI0aL0xlD+
+/jrGPxpwTlK7rtAArHiEVh/bsmwLYRlIxPBiU+NE/kqeT0lhJA6/e3X3KRmnpJ6u
+Z70PJGYwmZbSRsFom0vGFBMncX0qYx1iD0dooPeAkkSnM5j81rBh+FepV3HfF+tA
+9gaglWLsU7Z/A0fdn9D32UPreVHloOut5EyEinvXRUAWZco9CRloyEXJr7Mxq9MZ
+vYOK+5wuSPHnPij+fGlQsPxEgoTnCmU03PqNS49kL13jjEnYYdijqL+4rfHtuZbc
+4hlVoZfttwg8wD3m5brhQ+GkBBNYN8HFtdVy/BnFi+SPJqv6BgB3Y00=
+-----END CERTIFICATE-----
diff --git a/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/expired-cert.pem b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/expired-cert.pem
new file mode 100644
index 000000000000..ef9b25018ddc
--- /dev/null
+++ b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/expired-cert.pem
@@ -0,0 +1,19 @@
+-----BEGIN CERTIFICATE-----
+MIIDIzCCAgugAwIBAgIJAMw+KDBbSfSjMA0GCSqGSIb3DQEBCwUAMCgxJjAkBgNV
+BAMMHW15LXRlc3Qtc2VydmljZS5hbWF6b25hd3MuY29tMB4XDTI2MDMxMjIzNDMx
+OVoXDTI1MDMxMjIzNDMxOVowKDEmMCQGA1UEAwwdbXktdGVzdC1zZXJ2aWNlLmFt
+YXpvbmF3cy5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDIFTPw
+f/CQ3ShLHvVf7fbw9x6HyyxgJQmfMMPIeSf15WL6bWLCCO7CdAPmO5YFLIdZVm7/
+OG31kvpx7za2Uo44jPA8zD4ZI4QoHC2tF0JUfyrGej0NucZzS7iTMIfEnch2OA4I
+jCm4c7zl+/jSOjkub9XWE/H0s1Kh8edS6rrGRK9xRp7P1HKSKaNzBUAsLF5sKosv
+pp4YDKFdZjMj/gL2EsOvyN9buNlklkSux+kvSF4QvT2kj2FX4+IvdGRklXsLkDz8
+SDCDoaBuD6PTDjaD71Fo0D121zBr0/QuAww8sbPz3wabte8ZD1DU3qSs5rNIFfzd
+WiJEhATUr2KSedl/AgMBAAGjUDBOMB0GA1UdDgQWBBR/SEgi+B9DpxAoLKN+dWo7
+Pej2xjAfBgNVHSMEGDAWgBR/SEgi+B9DpxAoLKN+dWo7Pej2xjAMBgNVHRMEBTAD
+AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAaaAjlmD+C+OCyciaTvezvwY+ny8CBL68y
+DdmQeXdOwDLrnfOW8Hkb30dxA1jaKh/yoUZQwQO61g80GnvQq4pohtzdLv3mbSpY
+mKx/M+Iojl+uto+DK3mJAMAE1DQCLiW2Hp4AW2uCGetkCdJxIt3N0rlyPirH7Dmr
+X903nnrzmkwX6zne2orFJ3cJyXcgU9w6sz+fe3U32yJZfJCwDRobe0aykDnPamIs
+yWi6yZUmT2rbPiFH1SBqmSnaTIZApZuEYYjWWiCQ6cYsY9FwvKgafFzUaX8BLsDy
+gACoiolwd776w3imAo8ZYP5VbnR4ljCIPSrTKN72nWOjafrikeq7
+-----END CERTIFICATE-----
diff --git a/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-notification-no-subject.json b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-notification-no-subject.json
new file mode 100644
index 000000000000..45b2e4a01211
--- /dev/null
+++ b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-notification-no-subject.json
@@ -0,0 +1,11 @@
+{
+ "Type" : "Notification",
+ "MessageId" : "c0e5a280-53c9-5a98-93bb-c0a560b3d192",
+ "TopicArn" : "arn:aws:sns:us-west-2:131990247566:dongie-standard-topic",
+ "Message" : "This notification has no subject.",
+ "Timestamp" : "2026-03-06T19:55:38.903Z",
+ "SignatureVersion" : "1",
+ "Signature" : "h5/2kLNyPKvMH+ipOA1kueaA1GLFlcKbO/9o6GbH7BtKk/8lM8krPFDrV+Lwuq8yZDXTxeSXHR8pG1w9MkKQ3VlsHhMigy+5ejIsb+ZfpkDDO6dOACbkTg1twy9tLeKCyPfU01OokKTR6jlKJGCgXXDQm5Q1Fs4NfUz22xGqteycG1GIioqq2Hvd1PLsDkcoyUAhciugGFcZKIGjUbBtdhMTPFX1ed8iH+Y/p6o/eD0G2i614oRSXYRJyV2iNpl54LCCiCrAafAJAM5NXAJJq9CWyTjxHFC18FnPqSukW1/6Qa502oujhPJ/pyl+6mMBoF6LjnpbIyR8/Gmm+RY6fg==",
+ "SigningCertURL" : "https://sns.us-west-2.amazonaws.com/SimpleNotificationService-7506a1e35b36ef5a444dd1a8e7cc3ed8.pem",
+ "UnsubscribeURL" : "https://sns.us-west-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-west-2:131990247566:dongie-standard-topic:b24e11a2-06bd-4e73-aa29-0ed2de3b41b5"
+}
\ No newline at end of file
diff --git a/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-notification-signature-v2.json b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-notification-signature-v2.json
new file mode 100644
index 000000000000..2f946f540800
--- /dev/null
+++ b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-notification-signature-v2.json
@@ -0,0 +1,11 @@
+{
+ "Type" : "Notification",
+ "MessageId" : "9d2c5580-b61d-550c-8583-37a582870907",
+ "TopicArn" : "arn:aws:sns:us-west-2:131990247566:dongie-standard-topic",
+ "Message" : "This message uses signature version 2.",
+ "Timestamp" : "2026-03-06T20:00:26.893Z",
+ "SignatureVersion" : "2",
+ "Signature" : "kGNJ7FIM5DJxRv9bdoSJBty2WHpADFWrJ0ZQU/A2sALy6tBE5hqfUvPRlklTJ+PACFMguoFOO7W6ac/Bs4gR+JBRFhx4598X8/HWzrmaLrtB2f7nzViHbpzYN+u+OewXhxjj2lNsu0bLKv6fogkACytDIOhjZylbfJoPphDYAB7T3Ila7u+K3m2wLHH4k1cPYKyBf5FI/kAoQAVsY6qFHeb6CWfHsdM98I2gwyEvwJSzW0iyYBfqckmthQwH3Ex53jrhQlZylXFDYmstSJAdvbLeGqIUxKyk+O25wYgBjbbbeR+2qE2UCGEQrk1Bq018wmTxdHZmDhoBjJXiGmFkfg==",
+ "SigningCertURL" : "https://sns.us-west-2.amazonaws.com/SimpleNotificationService-7506a1e35b36ef5a444dd1a8e7cc3ed8.pem",
+ "UnsubscribeURL" : "https://sns.us-west-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-west-2:131990247566:dongie-standard-topic:b24e11a2-06bd-4e73-aa29-0ed2de3b41b5"
+}
\ No newline at end of file
diff --git a/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-notification-with-subject.json b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-notification-with-subject.json
new file mode 100644
index 000000000000..50636ad71652
--- /dev/null
+++ b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-notification-with-subject.json
@@ -0,0 +1,12 @@
+{
+ "Type" : "Notification",
+ "MessageId" : "26858270-545e-5848-82a2-e05566af3dcf",
+ "TopicArn" : "arn:aws:sns:us-west-2:131990247566:dongie-standard-topic",
+ "Subject" : "My message subject",
+ "Message" : "Hello! This message has a subject.",
+ "Timestamp" : "2026-03-06T19:53:21.424Z",
+ "SignatureVersion" : "1",
+ "Signature" : "jvNSgHVOF9KUOKI3bmQZhURD0kDN6txhfjiyuvnV95Dw6b2SRa7nq3AhM8agP0KUCIrRgKHqUlN6K2XBA35Lzkx1eI5RT0oXIN00pvvzGxSz0ddnuSGmDJM7bd/b1vl2RlLjz+KvkhzEtspxLnxjLsGl2EpOmztt+um7owb4475sOLdGTx1QiFV/8HqB2J0DyjeWdRZvu7Xpsf1Yd2rgmijFoz8x89Vw8pIksjczr4q16pxdh/XKWjbN2uPoC+3EeYdljL8KUvLYH9ueHSs5E9qq2iLdze4+FtANnE73XRvvSGE0KI+FGOvz1igest6qon3g1/CVy4mq3n19TsCSmA==",
+ "SigningCertURL" : "https://sns.us-west-2.amazonaws.com/SimpleNotificationService-7506a1e35b36ef5a444dd1a8e7cc3ed8.pem",
+ "UnsubscribeURL" : "https://sns.us-west-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-west-2:131990247566:dongie-standard-topic:b24e11a2-06bd-4e73-aa29-0ed2de3b41b5"
+}
\ No newline at end of file
diff --git a/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-subscription-confirmation.json b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-subscription-confirmation.json
new file mode 100644
index 000000000000..bc1ea5de2894
--- /dev/null
+++ b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-subscription-confirmation.json
@@ -0,0 +1,12 @@
+{
+ "Type" : "SubscriptionConfirmation",
+ "MessageId" : "f3f2fc87-c426-4c2f-9044-b55890c0939c",
+ "Token" : "2336412f37fb687f5d51e6e2425929f52e8e353134fe72d55ef8b330217d82da14007cbccefffd35b695feb8f0294d01fd9644f41e1ddfad261519a2c0bac743ff375eeb64530147d2da57f3acc54739da14c8dbd48a9d58ce996eff0d498ea6b4b9f58720d8959b52b99b8de148081720d7da17434e376614a00c992e7823e4",
+ "TopicArn" : "arn:aws:sns:us-west-2:131990247566:dongie-standard-topic",
+ "Message" : "You have chosen to subscribe to the topic arn:aws:sns:us-west-2:131990247566:dongie-standard-topic.\nTo confirm the subscription, visit the SubscribeURL included in this message.",
+ "SubscribeURL" : "https://sns.us-west-2.amazonaws.com/?Action=ConfirmSubscription&TopicArn=arn:aws:sns:us-west-2:131990247566:dongie-standard-topic&Token=2336412f37fb687f5d51e6e2425929f52e8e353134fe72d55ef8b330217d82da14007cbccefffd35b695feb8f0294d01fd9644f41e1ddfad261519a2c0bac743ff375eeb64530147d2da57f3acc54739da14c8dbd48a9d58ce996eff0d498ea6b4b9f58720d8959b52b99b8de148081720d7da17434e376614a00c992e7823e4",
+ "Timestamp" : "2026-03-06T19:31:32.895Z",
+ "SignatureVersion" : "1",
+ "Signature" : "Ce2Ax8UbEq0RrVH7gsjP9WZ9bzif7dUkAY4OH99Pv7FSrvs8cK+uiH3a6ig54rXWUnZRUejk8K6KeK5Q80O9iJp8KbPRKdQ0PkJ3YASdabhfK0e7evC05YR2pFoS6nNaqNzT7HrR1d1Z9iLoFxluo2Dzb7kWVB7e9jp/y+ZBXRzRIaZgscM84kahBc6QgXOFJA1/PFA4cB5qaa6tBcUFtTE63o9vJ4wMmKk93W9MoszC+dNJeVFr8VHmt3toR+3JauBCZnGdgXgsDhni4PduSfWJJxEpuOK41iYf1hW+xFNFFsv46siTFiHdHiYDfI7/VlaY4spMgkq3EndRr7rHzQ==",
+ "SigningCertURL" : "https://sns.us-west-2.amazonaws.com/SimpleNotificationService-7506a1e35b36ef5a444dd1a8e7cc3ed8.pem"
+}
\ No newline at end of file
diff --git a/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-unsubscribe-confirmation.json b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-unsubscribe-confirmation.json
new file mode 100644
index 000000000000..378cb97be672
--- /dev/null
+++ b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-unsubscribe-confirmation.json
@@ -0,0 +1,12 @@
+{
+ "Type" : "UnsubscribeConfirmation",
+ "MessageId" : "0b71e34e-42c3-486a-9e32-b1928ec994b5",
+ "Token" : "2336412f37fb687f5d51e6e2425929f52e8e353134fd0284eae5672847af082a982c7dd18215a1485e04e559716f8b42054594757f42dee2d63c46fd3a0fa59b4d688de03db956f8bf9f8cb3167587a28b74c5d325e6a7594d24a43ac534de2dfca614ca89cebd490342c721c30a76ea03def13673437a6a3e450642580119d0",
+ "TopicArn" : "arn:aws:sns:us-west-2:131990247566:dongie-standard-topic",
+ "Message" : "You have chosen to deactivate subscription arn:aws:sns:us-west-2:131990247566:dongie-standard-topic:b24e11a2-06bd-4e73-aa29-0ed2de3b41b5.\nTo cancel this operation and restore the subscription, visit the SubscribeURL included in this message.",
+ "SubscribeURL" : "https://sns.us-west-2.amazonaws.com/?Action=ConfirmSubscription&TopicArn=arn:aws:sns:us-west-2:131990247566:dongie-standard-topic&Token=2336412f37fb687f5d51e6e2425929f52e8e353134fd0284eae5672847af082a982c7dd18215a1485e04e559716f8b42054594757f42dee2d63c46fd3a0fa59b4d688de03db956f8bf9f8cb3167587a28b74c5d325e6a7594d24a43ac534de2dfca614ca89cebd490342c721c30a76ea03def13673437a6a3e450642580119d0",
+ "Timestamp" : "2026-03-06T20:02:25.038Z",
+ "SignatureVersion" : "2",
+ "Signature" : "RLW/cGWZAdY96iEcvXGmAZQ851DUyWehJIUMHT3Qa0/kX7Wu2QimI44TfE8d9hTqyYFreA6ln4AS67m225DuySpBeiUBtzB/ibiU7jdZmfSiT/I/RTEU3eD0lHHZIYW0M7HAlhHyaSXdcSAbtfnhMBMION93IVl6UOR4kWYdJaJ17/u3by3zlSRs8wCIpj06S6BzuzxrINZPJl+NKZYCVdMlrjuT4etX/UfZkC0Tjchpmu/CQcuimAgswlesm8cVue0WqNPXkOr8h7HmsiGVXPN1jjkYKf2/tVJyBxur/lSksMopER8wtAIsrqY1caAZSI8YJkIndg/b+7eCwkjTBA==",
+ "SigningCertURL" : "https://sns.us-west-2.amazonaws.com/SimpleNotificationService-7506a1e35b36ef5a444dd1a8e7cc3ed8.pem"
+}
\ No newline at end of file
diff --git a/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/valid-cert.pem b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/valid-cert.pem
new file mode 100644
index 000000000000..27815bf67865
--- /dev/null
+++ b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/valid-cert.pem
@@ -0,0 +1,18 @@
+-----BEGIN CERTIFICATE-----
+MIICzjCCAbYCCQCKg3/DeNKXtDANBgkqhkiG9w0BAQsFADAoMSYwJAYDVQQDDB1t
+eS10ZXN0LXNlcnZpY2UuYW1hem9uYXdzLmNvbTAgFw0yNjAzMTEyMjE4MzJaGA8y
+MTI2MDIxNTIyMTgzMlowKDEmMCQGA1UEAwwdbXktdGVzdC1zZXJ2aWNlLmFtYXpv
+bmF3cy5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCkTgR9/W8k
+xWv9dmxnNHqfJ9Qt4QPsF1qEO/WDwhBinS84wd54QoEraaZOkbcQeB2TSS64rAlr
+vy4W3H4XzqZayFraliZyuejmPpx0yI869YwTWclCttDflY/rlM/yXWtc+a7eL1S8
+StIiphT9qZKAwazI4hkB6W8ypvNwkowX7A+kWbuIcSfZCZqXIKZTaROwLl6NtErm
+ODj+kZnngmDqIHGwpKyIyNP35wysVkGgVJNO33nXPmhWp8OeUf8V2U/8BjFICb5m
+wPu05TLGqlvn1/3gN04J/kKS7HkRzPpZdAfSE1+4ft8e2A0wE3hFU9uKMgFETK5u
+j8Wg5LowqUkhAgMBAAEwDQYJKoZIhvcNAQELBQADggEBACCV6A+OGO8wVja56gxJ
+rKqC0TXnLte4dEIowzdSLYmcIZ4zLs4Vk7VHoV1JB+iv6VpP1pT4aq0F8nGHZL/1
+SjnsceNfgl6IPqLh6vPARxePtuqVEC6MFjEbvuqWBG0lyUiTt5N20K5sOtEd0b0W
+u7lJ83j9JM8RTpJveaT+VdOsiTyYxSGfayTdusk0AgnohkQSBYYUUFy11zr9gaqy
+Kxn/N17uVNMqVKJ14y3MTuEAlenZNi9157zm1r4sn6E+vsGB8wQhhJKc/iwTxnBI
+eQAumUfssCsgdLSQ7ESP28lXEqB4OTqRgbyXZE7uNkYF+Qj0+KX1Ksx2D1E/dt8j
+/7w=
+-----END CERTIFICATE-----
diff --git a/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/valid-in-future-cert.pem b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/valid-in-future-cert.pem
new file mode 100644
index 000000000000..9f4750f3773e
--- /dev/null
+++ b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/valid-in-future-cert.pem
@@ -0,0 +1,29 @@
+-----BEGIN CERTIFICATE-----
+MIIE+DCCAuCgAwIBAgIJAOCC5W/Vl4AEMA0GCSqGSIb3DQEBDAUAMCgxJjAkBgNV
+BAMTHW15LXRlc3Qtc2VydmljZS5hbWF6b25hd3MuY29tMCIYDzIxMjYwMzEyMjMz
+MjQxWhgPMjIyNjAzMTMyMzMyNDFaMCgxJjAkBgNVBAMTHW15LXRlc3Qtc2Vydmlj
+ZS5hbWF6b25hd3MuY29tMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA
+mmbD6gESHD9gjhhzlovEF2vVKiV12x2zwzj4J08aqi3Vxz8N8DjRTWBDXlecNjq+
+Uvxm2o299dWmIhawHCo0STCZUB4jBA2LreuuTTeU4XK0chU53Fkm2Cp5ib4qIeoE
+6XziBv00GVwvwjlglQRuz3rfFYirfJFXpQ9r8Vsbs9GbmTmXiUTtoVkx6CKd0eH4
+LKX4nx6PJfxfTr62r1fAhninyOhsUHx4KS9deKcZEUUqYTKXpTxWJ5STRysYr8y3
+Azlfo82ag8+1nLHq7GIrrsXUegXysQlWYOdBugVGM9YXH6CiJgDE71bc9KnAUNjY
+d2+GJhgnZ4LaeM/DYr84FTEmnkSCc01YC3MCOs0Olyb8jQlFNta7rLtGy+qBkFwh
+H0cab2UIiY6PWK/EEipDX3kuWWYIm1UFv0Nhm7U3eSwIk8ktFrFLXalUXAEwGrxI
+dXaE4BN1Vg2uLB09otDulHJrByii/g79bK6SC6OiaRUn9jG4cz3QidvjN08mPJNu
+/SZPw3z+rn4+v/Df8FprsvzPviBo0wdq/IkadFvpXOf2vqNrWv2JMxroCpQQbt8i
+tcUThqm9zA5KIMgXGxBfB0k+bmHCcwueBiG5nRD3pIXkz3f/Gb08dJZ/j31bREWA
+J5fwES49CXw6Z2+nrlZlWEHe4p6fZ3xvUfaoYYm46wECAwEAAaMhMB8wHQYDVR0O
+BBYEFLCmmUBwCbqQ9syEUc3+CIETGJ22MA0GCSqGSIb3DQEBDAUAA4ICAQA9mRPw
+HPXymnvbD2SYlZP3EEyBzTY4z5ILLjVG8bmvVb9bEcCSxKYmEV5CjRieQjVDCtlY
+GKoGjFxNsTMw56hDcpJfdWrGUm5sToXxR5ZHtp3zB/Q9FxnrWXD64Hq7ND+7e7yL
+ycSXsdc6WyQGp4pUKKX0EKDSjHTarDdOlm9E0PEFxJcgYSIUz5yeTPm7zkrO1eS/
+hN2QE7V9/5M583thUhnEWGyq3w8ZKsFwd+fIHLe4MIJMAUpIe8dCAVpluG0JuygC
+5Np1PzIKhBDQr4l3Py8uqwsAzP3wDhQ9yFN8jmYQfkUzSuARfukDj2d/rQZkhXhb
+2XWFEOaghmWj5cYFKXJVxUfkKbr2pc+/RG99YCV2plJm2R2SVBu3eWv2WjpprmP8
+TeNl7Za4WNCqody0Kfw4Y+IBXH8ANgFw7O9wz9IbnljISFhDsQNCxd3qj0N4HHoV
+xd+iOYWzwXMt7GMZnHrCu/WgdiwCJ2i+j3V6eg6xXJP2id84tnxXxxbn9sTFdQ09
+8GzH3+HStWOI650EynNWzksZFD3LGIXUU+6ctRe4jLqUtH3mUYLxrKj4/H1a9R4c
+fzlD9vfFu8CjbxarkkClq+pGZ9w/cQ3VxIZ9j7Rotq2OpvXzu8VQV8UE3VUl+jAx
+xFhoaR+M1F51+74yEuV/YtRYuQMfsdLRwYNXnw==
+-----END CERTIFICATE-----
diff --git a/test/architecture-tests/pom.xml b/test/architecture-tests/pom.xml
index 1516f32b7f42..ccc82bb7a7f8 100644
--- a/test/architecture-tests/pom.xml
+++ b/test/architecture-tests/pom.xml
@@ -121,6 +121,11 @@
s3-event-notifications
${awsjavasdk.version}