Skip to content

Commit 9655d04

Browse files
committed
Implement signature validation
1 parent b3d2bd1 commit 9655d04

File tree

9 files changed

+464
-5
lines changed

9 files changed

+464
-5
lines changed

services-custom/sns-message-manager/pom.xml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@
6868
<artifactId>sdk-core</artifactId>
6969
<version>${project.version}</version>
7070
</dependency>
71+
<dependency>
72+
<groupId>org.junit.jupiter</groupId>
73+
<artifactId>junit-jupiter</artifactId>
74+
<scope>test</scope>
75+
</dependency>
7176
<dependency>
7277
<groupId>software.amazon.awssdk</groupId>
7378
<artifactId>http-client-spi</artifactId>
@@ -78,11 +83,6 @@
7883
<artifactId>httpclient5</artifactId>
7984
<version>${httpcomponents.client5.version}</version>
8085
</dependency>
81-
<dependency>
82-
<groupId>org.junit.jupiter</groupId>
83-
<artifactId>junit-jupiter-engine</artifactId>
84-
<scope>test</scope>
85-
</dependency>
8686
<dependency>
8787
<groupId>org.assertj</groupId>
8888
<artifactId>assertj-core</artifactId>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
package software.amazon.awssdk.messagemanager.sns.internal;
17+
18+
import java.nio.charset.StandardCharsets;
19+
import java.security.InvalidKeyException;
20+
import java.security.NoSuchAlgorithmException;
21+
import java.security.PublicKey;
22+
import java.security.Signature;
23+
import java.security.SignatureException;
24+
import software.amazon.awssdk.annotations.SdkInternalApi;
25+
import software.amazon.awssdk.core.SdkBytes;
26+
import software.amazon.awssdk.core.exception.SdkClientException;
27+
import software.amazon.awssdk.messagemanager.sns.model.SignatureVersion;
28+
import software.amazon.awssdk.messagemanager.sns.model.SnsMessage;
29+
import software.amazon.awssdk.messagemanager.sns.model.SnsNotification;
30+
import software.amazon.awssdk.messagemanager.sns.model.SnsSubscriptionConfirmation;
31+
import software.amazon.awssdk.messagemanager.sns.model.SnsUnsubscribeConfirmation;
32+
import software.amazon.awssdk.utils.Logger;
33+
import software.amazon.awssdk.utils.Validate;
34+
35+
/**
36+
* See
37+
* <a href="https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message-verify-message-signature.html">
38+
* The official documentation.</a>
39+
*/
40+
@SdkInternalApi
41+
public final class SignatureValidator {
42+
private static final Logger LOG = Logger.loggerFor(SignatureValidator.class);
43+
44+
private static final String MESSAGE = "Message\n";
45+
private static final String MESSAGE_ID = "MessageId\n";
46+
private static final String SUBJECT = "Subject\n";
47+
private static final String SUBSCRIBE_URL = "SubscribeURL\n";
48+
private static final String TIMESTAMP = "Timestamp\n";
49+
private static final String TOKEN = "Token\n";
50+
private static final String TOPIC_ARN = "TopicArn\n";
51+
private static final String TYPE = "Type\n";
52+
53+
private static final String NEWLINE = "\n";
54+
55+
public void validateSignature(SnsMessage message, PublicKey publicKey) {
56+
Validate.paramNotNull(message, "message");
57+
Validate.paramNotNull(publicKey, "publicKey");
58+
59+
String canonicalMessage = buildCanonicalMessage(message);
60+
LOG.debug(() -> String.format("Canonical message: %s%n", canonicalMessage));
61+
62+
SdkBytes messageSignature = message.signature();
63+
if (messageSignature == null) {
64+
throw SdkClientException.create("Message signature cannot be null");
65+
}
66+
67+
SignatureVersion signatureVersion = message.signatureVersion();
68+
if (signatureVersion == null) {
69+
throw SdkClientException.create("Message signature version cannot be null");
70+
}
71+
72+
Signature signature = getSignature(signatureVersion);
73+
74+
verifySignature(canonicalMessage, messageSignature, publicKey, signature);
75+
}
76+
77+
private static String buildCanonicalMessage(SnsMessage message) {
78+
switch (message.type()) {
79+
case NOTIFICATION:
80+
return buildCanonicalMessage((SnsNotification) message);
81+
case SUBSCRIPTION_CONFIRMATION:
82+
return buildCanonicalMessage((SnsSubscriptionConfirmation) message);
83+
case UNSUBSCRIBE_CONFIRMATION:
84+
return buildCanonicalMessage((SnsUnsubscribeConfirmation) message);
85+
default:
86+
throw new IllegalStateException(String.format("Unsupported SNS message type: %s", message.type()));
87+
}
88+
}
89+
90+
private static String buildCanonicalMessage(SnsNotification notification) {
91+
StringBuilder sb = new StringBuilder();
92+
sb.append(MESSAGE)
93+
.append(notification.message()).append(NEWLINE)
94+
.append(MESSAGE_ID)
95+
.append(notification.messageId()).append(NEWLINE);
96+
97+
if (notification.subject() != null) {
98+
sb.append(SUBJECT)
99+
.append(notification.subject()).append(NEWLINE);
100+
}
101+
102+
sb.append(TIMESTAMP)
103+
.append(notification.timestamp()).append(NEWLINE)
104+
.append(TOPIC_ARN)
105+
.append(notification.topicArn()).append(NEWLINE)
106+
.append(TYPE)
107+
.append(notification.type()).append(NEWLINE);
108+
109+
return sb.toString();
110+
}
111+
112+
// Message, MessageId, SubscribeURL, Timestamp, Token, TopicArn, and Type.
113+
private static String buildCanonicalMessage(SnsSubscriptionConfirmation message) {
114+
StringBuilder sb = new StringBuilder();
115+
116+
sb.append(MESSAGE)
117+
.append(message.message()).append(NEWLINE)
118+
.append(MESSAGE_ID)
119+
.append(message.messageId()).append(NEWLINE)
120+
.append(SUBSCRIBE_URL)
121+
.append(message.subscribeUrl()).append(NEWLINE)
122+
.append(TIMESTAMP)
123+
.append(message.timestamp()).append(NEWLINE)
124+
.append(TOKEN)
125+
.append(message.token()).append(NEWLINE)
126+
.append(TOPIC_ARN)
127+
.append(message.topicArn()).append(NEWLINE)
128+
.append(TYPE)
129+
.append(message.type()).append(NEWLINE);
130+
131+
return sb.toString();
132+
}
133+
134+
// Message, MessageId, SubscribeURL, Timestamp, Token, TopicArn, and Type.
135+
private static String buildCanonicalMessage(SnsUnsubscribeConfirmation message) {
136+
StringBuilder sb = new StringBuilder();
137+
138+
sb.append(MESSAGE)
139+
.append(message.message()).append(NEWLINE)
140+
.append(MESSAGE_ID)
141+
.append(message.messageId()).append(NEWLINE)
142+
.append(SUBSCRIBE_URL)
143+
.append(message.subscribeUrl()).append(NEWLINE)
144+
.append(TIMESTAMP)
145+
.append(message.timestamp()).append(NEWLINE)
146+
.append(TOKEN)
147+
.append(message.token()).append(NEWLINE)
148+
.append(TOPIC_ARN)
149+
.append(message.topicArn()).append(NEWLINE)
150+
.append(TYPE)
151+
.append(message.type()).append(NEWLINE);
152+
153+
return sb.toString();
154+
}
155+
156+
private static void verifySignature(String canonicalMessage, SdkBytes messageSignature, PublicKey publicKey,
157+
Signature signature) {
158+
159+
try {
160+
signature.initVerify(publicKey);
161+
signature.update(canonicalMessage.getBytes(StandardCharsets.UTF_8));
162+
163+
boolean isValid = signature.verify(messageSignature.asByteArray());
164+
165+
if (!isValid) {
166+
throw SdkClientException.create("The computed signature did not match the expected signature");
167+
}
168+
} catch (InvalidKeyException e) {
169+
throw SdkClientException.create("The public key is invalid", e);
170+
} catch (SignatureException e) {
171+
throw SdkClientException.create("The signature is invalid", e);
172+
}
173+
}
174+
175+
private static Signature getSignature(SignatureVersion signatureVersion) {
176+
try {
177+
switch (signatureVersion) {
178+
case VERSION_1:
179+
return Signature.getInstance("SHA1withRSA");
180+
case VERSION_2:
181+
return Signature.getInstance("SHA256withRSA");
182+
default:
183+
throw new IllegalArgumentException("Unsupported signature version: " + signatureVersion);
184+
}
185+
} catch (NoSuchAlgorithmException e) {
186+
throw new RuntimeException("Unable to create Signature for " + signatureVersion, e);
187+
}
188+
}
189+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
package software.amazon.awssdk.messagemanager.sns.internal;
17+
18+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
19+
import static org.mockito.Mockito.mock;
20+
import static org.mockito.Mockito.when;
21+
22+
import java.io.InputStream;
23+
import java.net.URI;
24+
import java.security.PublicKey;
25+
import java.security.cert.CertificateException;
26+
import java.security.cert.CertificateFactory;
27+
import java.security.cert.X509Certificate;
28+
import java.time.Instant;
29+
import java.util.List;
30+
import java.util.stream.Collectors;
31+
import java.util.stream.Stream;
32+
import org.junit.jupiter.api.BeforeAll;
33+
import org.junit.jupiter.api.Test;
34+
import org.junit.jupiter.params.ParameterizedTest;
35+
import org.junit.jupiter.params.provider.MethodSource;
36+
import software.amazon.awssdk.core.SdkBytes;
37+
import software.amazon.awssdk.core.exception.SdkClientException;
38+
import software.amazon.awssdk.messagemanager.sns.model.SignatureVersion;
39+
import software.amazon.awssdk.messagemanager.sns.model.SnsMessage;
40+
import software.amazon.awssdk.messagemanager.sns.model.SnsNotification;
41+
42+
class SignatureValidatorTest {
43+
private static final String RESOURCE_ROOT = "/software/amazon/awssdk/messagemanager/sns/internal/";
44+
private static final String SIGNING_CERT_RESOURCE = "SimpleNotificationService-7506a1e35b36ef5a444dd1a8e7cc3ed8.pem";
45+
private static final SignatureValidator VALIDATOR = new SignatureValidator();
46+
private static X509Certificate signingCertificate;
47+
48+
@BeforeAll
49+
static void setup() throws CertificateException {
50+
InputStream is = resourceAsStream(SIGNING_CERT_RESOURCE);
51+
CertificateFactory factory = CertificateFactory.getInstance("X.509");
52+
signingCertificate = (X509Certificate) factory.generateCertificate(is);
53+
}
54+
55+
@ParameterizedTest(name = "{0}")
56+
@MethodSource("validMessages")
57+
void validateSignature_signatureValid_doesNotThrow(TestCase tc) {
58+
SnsMessageUnmarshaller unmarshaller = new SnsMessageUnmarshaller();
59+
SnsMessage msg = unmarshaller.unmarshall(resourceAsStream(tc.messageJsonResource));
60+
VALIDATOR.validateSignature(msg, signingCertificate.getPublicKey());
61+
}
62+
63+
@Test
64+
void validateSignature_signatureMismatch_throws() {
65+
SnsNotification notification = SnsNotification.builder()
66+
.message("hello world")
67+
.messageId("message-id")
68+
.signature(SdkBytes.fromByteArray(new byte[256]))
69+
.signatureVersion(SignatureVersion.VERSION_1)
70+
.build();
71+
72+
assertThatThrownBy(() -> VALIDATOR.validateSignature(notification, signingCertificate.getPublicKey()))
73+
.isInstanceOf(SdkClientException.class)
74+
.hasMessageContaining("The computed signature did not match the expected signature");
75+
}
76+
77+
@Test
78+
void validateSignature_signatureMissing_throws() {
79+
SnsNotification notification = SnsNotification.builder()
80+
.subject("hello world")
81+
.message("hello world")
82+
.messageId("message-id")
83+
.timestamp(Instant.now())
84+
.unsubscribeUrl(URI.create("https://my-test-service.amazonaws.com"))
85+
.signingCertUrl(URI.create("https://my-test-service.amazonaws.com/cert"
86+
+ ".pem"))
87+
.signatureVersion(SignatureVersion.VERSION_1)
88+
.build();
89+
90+
assertThatThrownBy(() -> VALIDATOR.validateSignature(notification, signingCertificate.getPublicKey()))
91+
.isInstanceOf(SdkClientException.class)
92+
.hasMessage("Message signature cannot be null");
93+
}
94+
95+
@Test
96+
void validateSignature_signatureVersionMissing_throws() {
97+
SnsNotification notification = SnsNotification.builder()
98+
.subject("hello world")
99+
.message("hello world")
100+
.messageId("message-id")
101+
.signature(SdkBytes.fromByteArray(new byte[256]))
102+
.timestamp(Instant.now())
103+
.unsubscribeUrl(URI.create("https://my-test-service.amazonaws.com"))
104+
.signingCertUrl(URI.create("https://my-test-service.amazonaws.com/cert"
105+
+ ".pem"))
106+
.build();
107+
108+
109+
assertThatThrownBy(() -> VALIDATOR.validateSignature(notification, signingCertificate.getPublicKey()))
110+
.isInstanceOf(SdkClientException.class)
111+
.hasMessage("Message signature version cannot be null");
112+
}
113+
114+
@Test
115+
void validateSignature_certInvalid_throws() throws CertificateException {
116+
SnsNotification notification = SnsNotification.builder()
117+
.signature(SdkBytes.fromByteArray(new byte[1]))
118+
.signatureVersion(SignatureVersion.VERSION_1)
119+
.build();
120+
121+
PublicKey badKey = mock(PublicKey.class);
122+
when(badKey.getFormat()).thenReturn("X.509");
123+
when(badKey.getAlgorithm()).thenReturn("RSA");
124+
when(badKey.getEncoded()).thenReturn(new byte[1]);
125+
126+
assertThatThrownBy(() -> VALIDATOR.validateSignature(notification, badKey))
127+
.isInstanceOf(SdkClientException.class)
128+
.hasMessage("The public key is invalid");
129+
}
130+
131+
@Test
132+
void validateSignature_signatureInvalid_throws() throws CertificateException {
133+
SnsNotification notification = SnsNotification.builder()
134+
.subject("hello world")
135+
.message("hello world")
136+
.messageId("message-id")
137+
.signature(SdkBytes.fromByteArray(new byte[1]))
138+
.signatureVersion(SignatureVersion.VERSION_1)
139+
.timestamp(Instant.now())
140+
.unsubscribeUrl(URI.create("https://my-test-service.amazonaws.com"))
141+
.signingCertUrl(URI.create("https://my-test-service.amazonaws.com/cert"
142+
+ ".pem"))
143+
.build();
144+
145+
assertThatThrownBy(() -> VALIDATOR.validateSignature(notification, signingCertificate.getPublicKey()))
146+
.isInstanceOf(SdkClientException.class)
147+
.hasMessage("The signature is invalid");
148+
}
149+
150+
private static List<TestCase> validMessages() {
151+
return Stream.of(
152+
new TestCase("Notification - No Subject", "test-notification-no-subject.json"),
153+
new TestCase("Notification - Version 2 signature", "test-notification-signature-v2.json"),
154+
new TestCase("Notification with subject", "test-notification-with-subject.json"),
155+
new TestCase("Subscription confirmation", "test-subscription-confirmation.json"),
156+
new TestCase("Unsubscribe confirmation", "test-unsubscribe-confirmation.json")
157+
)
158+
.collect(Collectors.toList());
159+
}
160+
161+
private static InputStream resourceAsStream(String resourceName) {
162+
return SignatureValidatorTest.class.getResourceAsStream(RESOURCE_ROOT + resourceName);
163+
}
164+
165+
private static class TestCase {
166+
private String desription;
167+
private String messageJsonResource;
168+
169+
public TestCase(String desription, String messageJsonResource) {
170+
this.desription = desription;
171+
this.messageJsonResource = messageJsonResource;
172+
}
173+
174+
@Override
175+
public String toString() {
176+
return desription;
177+
}
178+
}
179+
}

0 commit comments

Comments
 (0)