Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions services-custom/sns-message-manager/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@
<artifactId>sdk-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>regions</artifactId>
Expand All @@ -93,11 +98,6 @@
<artifactId>httpclient5</artifactId>
<version>${httpcomponents.client5.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -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
* <a href="https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message-verify-message-signature.html">
* The official documentation.</a>
*/
@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);

Check warning on line 168 in services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/SignatureValidator.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace generic exceptions with specific library exceptions or a custom exception.

See more on https://sonarcloud.io/project/issues?id=aws_aws-sdk-java-v2&issues=AZ0CJ3ttKSwp4fGsyeax&open=AZ0CJ3ttKSwp4fGsyeax&pullRequest=6800
}
}
}
Original file line number Diff line number Diff line change
@@ -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()))

Check warning on line 73 in services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/SignatureValidatorTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor the code of the lambda to have only one invocation possibly throwing a runtime exception.

See more on https://sonarcloud.io/project/issues?id=aws_aws-sdk-java-v2&issues=AZ0CJ3sjKSwp4fGsyeao&open=AZ0CJ3sjKSwp4fGsyeao&pullRequest=6800
.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()))

Check warning on line 91 in services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/SignatureValidatorTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor the code of the lambda to have only one invocation possibly throwing a runtime exception.

See more on https://sonarcloud.io/project/issues?id=aws_aws-sdk-java-v2&issues=AZ0CJ3sjKSwp4fGsyeap&open=AZ0CJ3sjKSwp4fGsyeap&pullRequest=6800
.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()))

Check warning on line 109 in services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/SignatureValidatorTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor the code of the lambda to have only one invocation possibly throwing a runtime exception.

See more on https://sonarcloud.io/project/issues?id=aws_aws-sdk-java-v2&issues=AZ0CJ3sjKSwp4fGsyeaq&open=AZ0CJ3sjKSwp4fGsyeaq&pullRequest=6800
.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()))

Check warning on line 165 in services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/SignatureValidatorTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor the code of the lambda to have only one invocation possibly throwing a runtime exception.

See more on https://sonarcloud.io/project/issues?id=aws_aws-sdk-java-v2&issues=AZ0CJ3sjKSwp4fGsyeau&open=AZ0CJ3sjKSwp4fGsyeau&pullRequest=6800
.isInstanceOf(SdkClientException.class)
.hasMessage("The signature is invalid");
}

private static List<TestCase> 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;
}
}
}
Loading
Loading