Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit c366485

Browse files
committedJan 27, 2025·
Init
1 parent 58d90ab commit c366485

11 files changed

+846
-0
lines changed
 
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.cosium.standard_webhooks_consumer;
2+
3+
import java.util.List;
4+
import java.util.Optional;
5+
6+
/**
7+
* @author Réda Housni Alaoui
8+
*/
9+
class CompositeVerificationKeyParser {
10+
11+
private final List<VerificationKeyParser> parsers;
12+
13+
CompositeVerificationKeyParser(VerificationKeyParser... parsers) {
14+
this.parsers = List.of(parsers);
15+
}
16+
17+
public VerificationKey parse(String serializedVerificationKey) {
18+
return parsers.stream()
19+
.map(verificationKeyParser -> verificationKeyParser.parse(serializedVerificationKey))
20+
.filter(Optional::isPresent)
21+
.map(Optional::get)
22+
.findFirst()
23+
.orElseThrow(
24+
() ->
25+
new IllegalArgumentException(
26+
"Could not parse verification key <%s>"
27+
.formatted(conceal(serializedVerificationKey))));
28+
}
29+
30+
private String conceal(String serializedVerificationKey) {
31+
int halfLength = Math.round(serializedVerificationKey.length() / 2f);
32+
return serializedVerificationKey.substring(0, halfLength)
33+
+ "*".repeat(serializedVerificationKey.length() - halfLength);
34+
}
35+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package com.cosium.standard_webhooks_consumer;
2+
3+
import static java.util.Objects.requireNonNull;
4+
5+
import java.net.http.HttpHeaders;
6+
import java.util.List;
7+
import java.util.Optional;
8+
import java.util.stream.Stream;
9+
import org.slf4j.Logger;
10+
import org.slf4j.LoggerFactory;
11+
12+
/**
13+
* @author Réda Housni Alaoui
14+
*/
15+
record IdentifiedSignature(SignatureSchemeId schemeId, Signature content) {
16+
17+
private static final Logger LOGGER = LoggerFactory.getLogger(IdentifiedSignature.class);
18+
19+
private static final String MESSAGE_SIGNATURE_HEADER_NAME = "webhook-signature";
20+
21+
IdentifiedSignature {
22+
requireNonNull(schemeId);
23+
requireNonNull(content);
24+
}
25+
26+
public static List<IdentifiedSignature> parseAtLeastOne(HttpHeaders headers)
27+
throws WebhookSignatureVerificationException {
28+
String messageSignatureHeaderValue =
29+
headers.firstValue(MESSAGE_SIGNATURE_HEADER_NAME).orElse(null);
30+
if (messageSignatureHeaderValue == null || messageSignatureHeaderValue.isBlank()) {
31+
throw new WebhookSignatureVerificationException(
32+
"No value found for header <%s>".formatted(MESSAGE_SIGNATURE_HEADER_NAME));
33+
}
34+
35+
List<IdentifiedSignature> signatures =
36+
Stream.of(messageSignatureHeaderValue.split(" "))
37+
.map(IdentifiedSignature::parse)
38+
.filter(Optional::isPresent)
39+
.map(Optional::get)
40+
.toList();
41+
42+
if (signatures.isEmpty()) {
43+
throw new WebhookSignatureVerificationException(
44+
"No well-formed signature(s) found for signature header value <%s>. A well-formed signature should have the form '$version,$base64encodedContent'."
45+
.formatted(messageSignatureHeaderValue));
46+
}
47+
48+
return signatures;
49+
}
50+
51+
private static Optional<IdentifiedSignature> parse(String messageSignature) {
52+
if (messageSignature == null || messageSignature.isBlank()) {
53+
return Optional.empty();
54+
}
55+
String[] signatureParts = messageSignature.split(",");
56+
if (signatureParts.length != 2) {
57+
return Optional.empty();
58+
}
59+
60+
IdentifiedSignature signature;
61+
try {
62+
signature =
63+
new IdentifiedSignature(
64+
new SignatureSchemeId(signatureParts[0]), new Signature(signatureParts[1]));
65+
} catch (RuntimeException e) {
66+
LOGGER.warn(e.getMessage());
67+
return Optional.empty();
68+
}
69+
70+
return Optional.of(signature);
71+
}
72+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package com.cosium.standard_webhooks_consumer;
2+
3+
import static java.util.Objects.requireNonNull;
4+
5+
import java.nio.charset.StandardCharsets;
6+
import java.security.InvalidKeyException;
7+
import java.security.KeyFactory;
8+
import java.security.NoSuchAlgorithmException;
9+
import java.security.SignatureException;
10+
import java.security.spec.InvalidKeySpecException;
11+
import java.security.spec.X509EncodedKeySpec;
12+
import java.util.Base64;
13+
import java.util.Optional;
14+
15+
/**
16+
* @author Réda Housni Alaoui
17+
*/
18+
class PublicKey implements VerificationKey {
19+
20+
private static final String SERIALIZATION_PREFIX = "whpk_";
21+
private static final SignatureSchemeId SCHEME_ID = new SignatureSchemeId("v1a");
22+
private static final String ALGORITHM = "Ed25519";
23+
24+
private final byte[] value;
25+
26+
private PublicKey(byte[] value) {
27+
this.value = requireNonNull(value);
28+
}
29+
30+
public static Optional<PublicKey> parseKey(String serializedVerificationKey) {
31+
if (!serializedVerificationKey.startsWith(SERIALIZATION_PREFIX)) {
32+
return Optional.empty();
33+
}
34+
return Optional.of(
35+
new PublicKey(
36+
Base64.getDecoder()
37+
.decode(serializedVerificationKey.substring(SERIALIZATION_PREFIX.length()))));
38+
}
39+
40+
@Override
41+
public boolean supports(SignatureSchemeId signatureSchemeId) {
42+
return SCHEME_ID.equals(signatureSchemeId);
43+
}
44+
45+
@Override
46+
public void verify(String messageId, long timestamp, String payload, Signature signatureToVerify)
47+
throws WebhookSignatureVerificationException {
48+
try {
49+
doVerify(messageId, timestamp, payload, signatureToVerify);
50+
} catch (NoSuchAlgorithmException
51+
| InvalidKeyException
52+
| SignatureException
53+
| InvalidKeySpecException e) {
54+
throw new WebhookSignatureVerificationException(e);
55+
}
56+
}
57+
58+
private void doVerify(
59+
String messageId, long timestamp, String payload, Signature signatureToVerify)
60+
throws NoSuchAlgorithmException,
61+
InvalidKeyException,
62+
SignatureException,
63+
InvalidKeySpecException,
64+
WebhookSignatureVerificationException {
65+
66+
java.security.Signature signature = java.security.Signature.getInstance(ALGORITHM);
67+
68+
java.security.PublicKey publicKey =
69+
KeyFactory.getInstance(ALGORITHM)
70+
.generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(value), ALGORITHM));
71+
72+
String signedContent = "%s.%s.%s".formatted(messageId, timestamp, payload);
73+
signature.initVerify(publicKey);
74+
signature.update(signedContent.getBytes(StandardCharsets.UTF_8));
75+
76+
if (signature.verify(signatureToVerify.decode())) {
77+
return;
78+
}
79+
80+
throw new WebhookSignatureVerificationException("%s is not valid".formatted(signatureToVerify));
81+
}
82+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package com.cosium.standard_webhooks_consumer;
2+
3+
import static java.util.Objects.requireNonNull;
4+
5+
import java.nio.charset.StandardCharsets;
6+
import java.security.InvalidKeyException;
7+
import java.security.NoSuchAlgorithmException;
8+
import java.util.Base64;
9+
import java.util.Optional;
10+
import javax.crypto.Mac;
11+
import javax.crypto.spec.SecretKeySpec;
12+
13+
/**
14+
* @author Réda Housni Alaoui
15+
*/
16+
class SecretKey implements VerificationKey {
17+
18+
private static final String SERIALIZATION_PREFIX = "whsec_";
19+
private static final SignatureSchemeId SCHEME_ID = new SignatureSchemeId("v1");
20+
private static final String ALGORITHM = "HmacSHA256";
21+
22+
private final byte[] value;
23+
24+
private SecretKey(byte[] value) {
25+
this.value = requireNonNull(value);
26+
}
27+
28+
public static Optional<SecretKey> parseKey(String serializedVerificationKey) {
29+
if (!serializedVerificationKey.startsWith(SERIALIZATION_PREFIX)) {
30+
return Optional.empty();
31+
}
32+
return Optional.of(
33+
new SecretKey(
34+
Base64.getDecoder()
35+
.decode(serializedVerificationKey.substring(SERIALIZATION_PREFIX.length()))));
36+
}
37+
38+
@Override
39+
public boolean supports(SignatureSchemeId signatureSchemeId) {
40+
return SCHEME_ID.equals(signatureSchemeId);
41+
}
42+
43+
@Override
44+
public void verify(String messageId, long timestamp, String payload, Signature signatureToVerify)
45+
throws WebhookSignatureVerificationException {
46+
47+
String expectedBase64EncodedSignatureContent;
48+
try {
49+
expectedBase64EncodedSignatureContent = sign(messageId, timestamp, payload);
50+
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
51+
throw new WebhookSignatureVerificationException(e);
52+
}
53+
54+
if (expectedBase64EncodedSignatureContent.equals(signatureToVerify.base64EncodedValue())) {
55+
return;
56+
}
57+
58+
throw new WebhookSignatureVerificationException(
59+
"The provided signature <%s> does not match the expected signature <%s>."
60+
.formatted(
61+
signatureToVerify.base64EncodedValue(), expectedBase64EncodedSignatureContent));
62+
}
63+
64+
private String sign(String messageId, long timestamp, String payload)
65+
throws NoSuchAlgorithmException, InvalidKeyException {
66+
String contentToSign = "%s.%s.%s".formatted(messageId, timestamp, payload);
67+
Mac sha512Hmac = Mac.getInstance(ALGORITHM);
68+
SecretKeySpec keySpec = new SecretKeySpec(value, ALGORITHM);
69+
sha512Hmac.init(keySpec);
70+
byte[] macData = sha512Hmac.doFinal(contentToSign.getBytes(StandardCharsets.UTF_8));
71+
return Base64.getEncoder().encodeToString(macData);
72+
}
73+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.cosium.standard_webhooks_consumer;
2+
3+
import java.util.Base64;
4+
5+
/**
6+
* @author Réda Housni Alaoui
7+
*/
8+
record Signature(String base64EncodedValue) {
9+
10+
Signature {
11+
if (base64EncodedValue == null || base64EncodedValue.isBlank()) {
12+
throw new IllegalArgumentException("base64EncodedValue cannot be blank");
13+
}
14+
}
15+
16+
public byte[] decode() {
17+
return Base64.getDecoder().decode(base64EncodedValue);
18+
}
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.cosium.standard_webhooks_consumer;
2+
3+
/**
4+
* @author Réda Housni Alaoui
5+
*/
6+
record SignatureSchemeId(String value) {
7+
8+
SignatureSchemeId {
9+
if (value == null || value.isBlank()) {
10+
throw new IllegalArgumentException("The value cannot be blank");
11+
}
12+
}
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.cosium.standard_webhooks_consumer;
2+
3+
/**
4+
* @author Réda Housni Alaoui
5+
*/
6+
interface VerificationKey {
7+
8+
boolean supports(SignatureSchemeId signatureSchemeId);
9+
10+
void verify(String messageId, long timestamp, String payload, Signature signatureToVerify)
11+
throws WebhookSignatureVerificationException;
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.cosium.standard_webhooks_consumer;
2+
3+
import java.util.Optional;
4+
5+
/**
6+
* @author Réda Housni Alaoui
7+
*/
8+
interface VerificationKeyParser {
9+
10+
Optional<? extends VerificationKey> parse(String serializedVerificationKey);
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.cosium.standard_webhooks_consumer;
2+
3+
/**
4+
* @author Réda Housni Alaoui
5+
*/
6+
public final class WebhookSignatureVerificationException extends Exception {
7+
8+
WebhookSignatureVerificationException(Throwable cause) {
9+
super(cause);
10+
}
11+
12+
WebhookSignatureVerificationException(String message) {
13+
super(message);
14+
}
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package com.cosium.standard_webhooks_consumer;
2+
3+
import static java.util.Objects.requireNonNull;
4+
5+
import java.net.http.HttpHeaders;
6+
import java.time.Clock;
7+
import java.time.Duration;
8+
import java.util.ArrayList;
9+
import java.util.List;
10+
import org.slf4j.Logger;
11+
import org.slf4j.LoggerFactory;
12+
13+
/**
14+
* @author Réda Housni Alaoui
15+
*/
16+
public class WebhookSignatureVerifier {
17+
18+
private static final Logger LOGGER = LoggerFactory.getLogger(WebhookSignatureVerifier.class);
19+
20+
private static final CompositeVerificationKeyParser VERIFICATION_KEY_PARSER =
21+
new CompositeVerificationKeyParser(SecretKey::parseKey, PublicKey::parseKey);
22+
23+
private static final String MESSAGE_ID_HEADER_NAME = "webhook-id";
24+
private static final String MESSAGE_TIMESTAMP_HEADER_NAME = "webhook-timestamp";
25+
26+
private static final Duration DEFAULT_MESSAGE_TIMESTAMP_ALLOWED_SKEW = Duration.ofMinutes(5);
27+
28+
private final List<VerificationKey> verificationKeys;
29+
private final Clock clock;
30+
private final Duration messageTimestampAllowedSkew;
31+
32+
private WebhookSignatureVerifier(Builder builder) {
33+
34+
verificationKeys =
35+
builder.serializedVerificationKeys.stream().map(VERIFICATION_KEY_PARSER::parse).toList();
36+
clock = builder.clock;
37+
messageTimestampAllowedSkew = builder.messageTimestampAllowedSkew;
38+
}
39+
40+
/**
41+
* @param serializedVerificationKey e.g. "v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4="
42+
*/
43+
public static Builder builder(String serializedVerificationKey) {
44+
return new Builder(serializedVerificationKey);
45+
}
46+
47+
public void verify(HttpHeaders headers, String payload)
48+
throws WebhookSignatureVerificationException {
49+
50+
String messageId = headers.firstValue(MESSAGE_ID_HEADER_NAME).orElse(null);
51+
if (messageId == null || messageId.isBlank()) {
52+
throw new WebhookSignatureVerificationException(
53+
"No value found for header <%s>".formatted(MESSAGE_ID_HEADER_NAME));
54+
}
55+
56+
String messageTimestampAsString =
57+
headers.firstValue(MESSAGE_TIMESTAMP_HEADER_NAME).orElse(null);
58+
if (messageTimestampAsString == null || messageTimestampAsString.isBlank()) {
59+
throw new WebhookSignatureVerificationException(
60+
"No value found for header <%s>".formatted(MESSAGE_TIMESTAMP_HEADER_NAME));
61+
}
62+
63+
long timestamp = verifyTimestamp(messageTimestampAsString);
64+
65+
List<IdentifiedSignature> signatures = IdentifiedSignature.parseAtLeastOne(headers);
66+
for (IdentifiedSignature signature : signatures) {
67+
for (VerificationKey verificationKey : verificationKeys) {
68+
SignatureSchemeId signatureSchemeId = signature.schemeId();
69+
if (!verificationKey.supports(signatureSchemeId)) {
70+
LOGGER.debug("{} does not support {}", verificationKey, signatureSchemeId);
71+
continue;
72+
}
73+
verificationKey.verify(messageId, timestamp, payload, signature.content());
74+
return;
75+
}
76+
}
77+
78+
throw new WebhookSignatureVerificationException(
79+
"No supporting verification key found for any signature among %s".formatted(signatures));
80+
}
81+
82+
private long verifyTimestamp(String messageTimestamp)
83+
throws WebhookSignatureVerificationException {
84+
long nowInSeconds = Duration.ofMillis(clock.millis()).toSeconds();
85+
86+
long timestamp;
87+
try {
88+
timestamp = Long.parseLong(messageTimestamp);
89+
} catch (NumberFormatException e) {
90+
throw new WebhookSignatureVerificationException(
91+
"Cannot parse timestamp <%s>".formatted(messageTimestamp));
92+
}
93+
94+
if (timestamp < (nowInSeconds - messageTimestampAllowedSkew.toSeconds())) {
95+
throw new WebhookSignatureVerificationException(
96+
"Message timestamp <%s seconds> is too old compared to the current timestamp <%s seconds>"
97+
.formatted(timestamp, nowInSeconds));
98+
}
99+
if (timestamp > (nowInSeconds + messageTimestampAllowedSkew.toSeconds())) {
100+
throw new WebhookSignatureVerificationException(
101+
"Message timestamp <%s seconds> is too new compared to the current timestamp <%s seconds>"
102+
.formatted(timestamp, nowInSeconds));
103+
}
104+
return timestamp;
105+
}
106+
107+
public static class Builder {
108+
private final List<String> serializedVerificationKeys = new ArrayList<>();
109+
private Duration messageTimestampAllowedSkew = DEFAULT_MESSAGE_TIMESTAMP_ALLOWED_SKEW;
110+
private Clock clock = Clock.systemDefaultZone();
111+
112+
private Builder(String serializedVerificationKey) {
113+
serializedVerificationKeys.add(requireNonNull(serializedVerificationKey));
114+
}
115+
116+
/**
117+
* @param serializedVerificationKey e.g. "v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4="
118+
*/
119+
public Builder addSerializedVerificationKey(String serializedVerificationKey) {
120+
serializedVerificationKeys.add(requireNonNull(serializedVerificationKey));
121+
return this;
122+
}
123+
124+
/**
125+
* @param messageTimestampAllowedSkew Allowable tolerance of the current timestamp to prevent
126+
* replay attacks.
127+
*/
128+
public Builder messageTimestampAllowedSkew(Duration messageTimestampAllowedSkew) {
129+
this.messageTimestampAllowedSkew = requireNonNull(messageTimestampAllowedSkew);
130+
return this;
131+
}
132+
133+
public Builder clock(Clock clock) {
134+
this.clock = requireNonNull(clock);
135+
return this;
136+
}
137+
138+
public WebhookSignatureVerifier build() {
139+
return new WebhookSignatureVerifier(this);
140+
}
141+
}
142+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,372 @@
1+
package com.cosium.standard_webhooks_consumer;
2+
3+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
4+
5+
import java.net.http.HttpHeaders;
6+
import java.time.Clock;
7+
import java.time.Duration;
8+
import java.time.Instant;
9+
import java.time.ZoneId;
10+
import java.util.List;
11+
import java.util.Map;
12+
import java.util.stream.Collectors;
13+
import org.junit.jupiter.api.DisplayName;
14+
import org.junit.jupiter.api.Test;
15+
16+
/**
17+
* @author Réda Housni Alaoui
18+
*/
19+
class SymmetricWebhookSignatureVerifierTest {
20+
21+
@Test
22+
@DisplayName("Verify valid signature")
23+
void test1() throws WebhookSignatureVerificationException {
24+
WebhookSignatureVerifier verifier =
25+
WebhookSignatureVerifier.builder("whsec_b6Ovv5eS7H5seJrGSStBYDivs8v2/KrFjfMaVZYsi7w=")
26+
.clock(Clock.fixed(Instant.ofEpochSecond(1737987215), ZoneId.systemDefault()))
27+
.build();
28+
HttpHeaders httpHeaders =
29+
createHttpHeaders(
30+
Map.of(
31+
"webhook-id",
32+
"7a2486b3-31cf-4bd3-a460-df8845d16cd5",
33+
"webhook-timestamp",
34+
String.valueOf(1737987215),
35+
"webhook-signature",
36+
"v1,iayM3VaiYCEDP/CxWUFWcxUCJk2YmBDQHtHTsaHzrwo="));
37+
verifier.verify(httpHeaders, "{\"greetings\": \"Hello World\"}");
38+
}
39+
40+
@Test
41+
@DisplayName("Verify invalid signature")
42+
void test2() {
43+
WebhookSignatureVerifier verifier =
44+
WebhookSignatureVerifier.builder("whsec_b6Ovv5eS7H5seJrGSStBYDivs8v2/KrFjfMaVZYsi7w=")
45+
.clock(Clock.fixed(Instant.ofEpochSecond(1737987215), ZoneId.systemDefault()))
46+
.build();
47+
HttpHeaders httpHeaders =
48+
createHttpHeaders(
49+
Map.of(
50+
"webhook-id",
51+
"7a2486b3-31cf-4bd3-a460-df8845d16cd5",
52+
"webhook-timestamp",
53+
String.valueOf(1737987215),
54+
"webhook-signature",
55+
"v1,iayM3VaiYCEDP/CxWUFWcxUCJk2YmBDQHtHTsaHzrwo"));
56+
57+
assertThatThrownBy(() -> verifier.verify(httpHeaders, "{\"greetings\": \"Hello World\"}"))
58+
.isInstanceOf(WebhookSignatureVerificationException.class)
59+
.hasMessageContaining(
60+
"The provided signature <iayM3VaiYCEDP/CxWUFWcxUCJk2YmBDQHtHTsaHzrwo> does not match the expected signature");
61+
}
62+
63+
@Test
64+
@DisplayName("Verify invalid payload")
65+
void test3() {
66+
WebhookSignatureVerifier verifier =
67+
WebhookSignatureVerifier.builder("whsec_b6Ovv5eS7H5seJrGSStBYDivs8v2/KrFjfMaVZYsi7w=")
68+
.clock(Clock.fixed(Instant.ofEpochSecond(1737987215), ZoneId.systemDefault()))
69+
.build();
70+
HttpHeaders httpHeaders =
71+
createHttpHeaders(
72+
Map.of(
73+
"webhook-id",
74+
"7a2486b3-31cf-4bd3-a460-df8845d16cd5",
75+
"webhook-timestamp",
76+
String.valueOf(1737987215),
77+
"webhook-signature",
78+
"v1,iayM3VaiYCEDP/CxWUFWcxUCJk2YmBDQHtHTsaHzrwo="));
79+
80+
assertThatThrownBy(() -> verifier.verify(httpHeaders, "{\"greetings\": \"Hello\"}"))
81+
.isInstanceOf(WebhookSignatureVerificationException.class)
82+
.hasMessageContaining(
83+
"The provided signature <iayM3VaiYCEDP/CxWUFWcxUCJk2YmBDQHtHTsaHzrwo=> does not match the expected signature");
84+
}
85+
86+
@Test
87+
@DisplayName("Verify invalid timestamp")
88+
void test4() {
89+
WebhookSignatureVerifier verifier =
90+
WebhookSignatureVerifier.builder("whsec_b6Ovv5eS7H5seJrGSStBYDivs8v2/KrFjfMaVZYsi7w=")
91+
.clock(Clock.fixed(Instant.ofEpochSecond(1737987215), ZoneId.systemDefault()))
92+
.build();
93+
HttpHeaders httpHeaders =
94+
createHttpHeaders(
95+
Map.of(
96+
"webhook-id",
97+
"7a2486b3-31cf-4bd3-a460-df8845d16cd5",
98+
"webhook-timestamp",
99+
String.valueOf(1737987216),
100+
"webhook-signature",
101+
"v1,iayM3VaiYCEDP/CxWUFWcxUCJk2YmBDQHtHTsaHzrwo="));
102+
103+
assertThatThrownBy(() -> verifier.verify(httpHeaders, "{\"greetings\": \"Hello World\"}"))
104+
.isInstanceOf(WebhookSignatureVerificationException.class)
105+
.hasMessageContaining(
106+
"The provided signature <iayM3VaiYCEDP/CxWUFWcxUCJk2YmBDQHtHTsaHzrwo=> does not match the expected signature");
107+
}
108+
109+
@Test
110+
@DisplayName("Verify invalid message id")
111+
void test5() {
112+
WebhookSignatureVerifier verifier =
113+
WebhookSignatureVerifier.builder("whsec_b6Ovv5eS7H5seJrGSStBYDivs8v2/KrFjfMaVZYsi7w=")
114+
.clock(Clock.fixed(Instant.ofEpochSecond(1737987215), ZoneId.systemDefault()))
115+
.build();
116+
HttpHeaders httpHeaders =
117+
createHttpHeaders(
118+
Map.of(
119+
"webhook-id",
120+
"7a2486b3-31cf-4bd3-a460-df8845d16cd6",
121+
"webhook-timestamp",
122+
String.valueOf(1737987215),
123+
"webhook-signature",
124+
"v1,iayM3VaiYCEDP/CxWUFWcxUCJk2YmBDQHtHTsaHzrwo="));
125+
126+
assertThatThrownBy(() -> verifier.verify(httpHeaders, "{\"greetings\": \"Hello World\"}"))
127+
.isInstanceOf(WebhookSignatureVerificationException.class)
128+
.hasMessageContaining(
129+
"The provided signature <iayM3VaiYCEDP/CxWUFWcxUCJk2YmBDQHtHTsaHzrwo=> does not match the expected signature");
130+
}
131+
132+
@Test
133+
@DisplayName("Build with invalid verification key")
134+
void test6() {
135+
WebhookSignatureVerifier.Builder builder =
136+
WebhookSignatureVerifier.builder("foo_b6Ovv5eS7H5seJrGSStBYDivs8v2/KrFjfMaVZYsi7w=")
137+
.clock(Clock.fixed(Instant.ofEpochSecond(1737987215), ZoneId.systemDefault()));
138+
139+
assertThatThrownBy(builder::build)
140+
.isInstanceOf(RuntimeException.class)
141+
.hasMessageContaining(
142+
"Could not parse verification key <foo_b6Ovv5eS7H5seJrGSStB************************>");
143+
}
144+
145+
@Test
146+
@DisplayName("Verify missing signature header")
147+
void test7() {
148+
WebhookSignatureVerifier verifier =
149+
WebhookSignatureVerifier.builder("whsec_b6Ovv5eS7H5seJrGSStBYDivs8v2/KrFjfMaVZYsi7w=")
150+
.clock(Clock.fixed(Instant.ofEpochSecond(1737987215), ZoneId.systemDefault()))
151+
.build();
152+
HttpHeaders httpHeaders =
153+
createHttpHeaders(
154+
Map.of(
155+
"webhook-id",
156+
"7a2486b3-31cf-4bd3-a460-df8845d16cd5",
157+
"webhook-timestamp",
158+
String.valueOf(1737987215)));
159+
160+
assertThatThrownBy(() -> verifier.verify(httpHeaders, "{\"greetings\": \"Hello World\"}"))
161+
.isInstanceOf(WebhookSignatureVerificationException.class)
162+
.hasMessageContaining("No value found for header <webhook-signature>");
163+
}
164+
165+
@Test
166+
@DisplayName("Verify blank signature header")
167+
void test8() {
168+
WebhookSignatureVerifier verifier =
169+
WebhookSignatureVerifier.builder("whsec_b6Ovv5eS7H5seJrGSStBYDivs8v2/KrFjfMaVZYsi7w=")
170+
.clock(Clock.fixed(Instant.ofEpochSecond(1737987215), ZoneId.systemDefault()))
171+
.build();
172+
HttpHeaders httpHeaders =
173+
createHttpHeaders(
174+
Map.of(
175+
"webhook-id",
176+
"7a2486b3-31cf-4bd3-a460-df8845d16cd5",
177+
"webhook-timestamp",
178+
String.valueOf(1737987215),
179+
"webhook-signature",
180+
" "));
181+
182+
assertThatThrownBy(() -> verifier.verify(httpHeaders, "{\"greetings\": \"Hello World\"}"))
183+
.isInstanceOf(WebhookSignatureVerificationException.class)
184+
.hasMessageContaining("No value found for header <webhook-signature>");
185+
}
186+
187+
@Test
188+
@DisplayName("Verify malformed signature header")
189+
void test9() {
190+
WebhookSignatureVerifier verifier =
191+
WebhookSignatureVerifier.builder("whsec_b6Ovv5eS7H5seJrGSStBYDivs8v2/KrFjfMaVZYsi7w=")
192+
.clock(Clock.fixed(Instant.ofEpochSecond(1737987215), ZoneId.systemDefault()))
193+
.build();
194+
HttpHeaders httpHeaders =
195+
createHttpHeaders(
196+
Map.of(
197+
"webhook-id",
198+
"7a2486b3-31cf-4bd3-a460-df8845d16cd5",
199+
"webhook-timestamp",
200+
String.valueOf(1737987215),
201+
"webhook-signature",
202+
"iayM3VaiYCEDP/CxWUFWcxUCJk2YmBDQHtHTsaHzrwo="));
203+
204+
assertThatThrownBy(() -> verifier.verify(httpHeaders, "{\"greetings\": \"Hello World\"}"))
205+
.isInstanceOf(WebhookSignatureVerificationException.class)
206+
.hasMessageContaining(
207+
"No well-formed signature(s) found for signature header value <iayM3VaiYCEDP/CxWUFWcxUCJk2YmBDQHtHTsaHzrwo=>. A well-formed signature should have the form '$version,$base64encodedContent'.");
208+
}
209+
210+
@Test
211+
@DisplayName("Verify malformed signature header")
212+
void test10() {
213+
WebhookSignatureVerifier verifier =
214+
WebhookSignatureVerifier.builder("whsec_b6Ovv5eS7H5seJrGSStBYDivs8v2/KrFjfMaVZYsi7w=")
215+
.clock(Clock.fixed(Instant.ofEpochSecond(1737987215), ZoneId.systemDefault()))
216+
.build();
217+
HttpHeaders httpHeaders =
218+
createHttpHeaders(
219+
Map.of(
220+
"webhook-id",
221+
"7a2486b3-31cf-4bd3-a460-df8845d16cd5",
222+
"webhook-timestamp",
223+
String.valueOf(1737987215),
224+
"webhook-signature",
225+
",iayM3VaiYCEDP/CxWUFWcxUCJk2YmBDQHtHTsaHzrwo="));
226+
227+
assertThatThrownBy(() -> verifier.verify(httpHeaders, "{\"greetings\": \"Hello World\"}"))
228+
.isInstanceOf(WebhookSignatureVerificationException.class)
229+
.hasMessageContaining(
230+
"No well-formed signature(s) found for signature header value <,iayM3VaiYCEDP/CxWUFWcxUCJk2YmBDQHtHTsaHzrwo=>. A well-formed signature should have the form '$version,$base64encodedContent'.");
231+
}
232+
233+
@Test
234+
@DisplayName("Verify missing message id")
235+
void test11() {
236+
WebhookSignatureVerifier verifier =
237+
WebhookSignatureVerifier.builder("whsec_b6Ovv5eS7H5seJrGSStBYDivs8v2/KrFjfMaVZYsi7w=")
238+
.clock(Clock.fixed(Instant.ofEpochSecond(1737987215), ZoneId.systemDefault()))
239+
.build();
240+
HttpHeaders httpHeaders =
241+
createHttpHeaders(
242+
Map.of(
243+
"webhook-timestamp",
244+
String.valueOf(1737987215),
245+
"webhook-signature",
246+
"v1,iayM3VaiYCEDP/CxWUFWcxUCJk2YmBDQHtHTsaHzrwo="));
247+
248+
assertThatThrownBy(() -> verifier.verify(httpHeaders, "{\"greetings\": \"Hello World\"}"))
249+
.isInstanceOf(WebhookSignatureVerificationException.class)
250+
.hasMessageContaining("No value found for header <webhook-id>");
251+
}
252+
253+
@Test
254+
@DisplayName("Verify blank message id")
255+
void test12() {
256+
WebhookSignatureVerifier verifier =
257+
WebhookSignatureVerifier.builder("whsec_b6Ovv5eS7H5seJrGSStBYDivs8v2/KrFjfMaVZYsi7w=")
258+
.clock(Clock.fixed(Instant.ofEpochSecond(1737987215), ZoneId.systemDefault()))
259+
.build();
260+
HttpHeaders httpHeaders =
261+
createHttpHeaders(
262+
Map.of(
263+
"webhook-id",
264+
" ",
265+
"webhook-timestamp",
266+
String.valueOf(1737987215),
267+
"webhook-signature",
268+
"v1,iayM3VaiYCEDP/CxWUFWcxUCJk2YmBDQHtHTsaHzrwo="));
269+
270+
assertThatThrownBy(() -> verifier.verify(httpHeaders, "{\"greetings\": \"Hello World\"}"))
271+
.isInstanceOf(WebhookSignatureVerificationException.class)
272+
.hasMessageContaining("No value found for header <webhook-id>");
273+
}
274+
275+
@Test
276+
@DisplayName("Verify missing timestamp")
277+
void test13() {
278+
WebhookSignatureVerifier verifier =
279+
WebhookSignatureVerifier.builder("whsec_b6Ovv5eS7H5seJrGSStBYDivs8v2/KrFjfMaVZYsi7w=")
280+
.clock(Clock.fixed(Instant.ofEpochSecond(1737987215), ZoneId.systemDefault()))
281+
.build();
282+
HttpHeaders httpHeaders =
283+
createHttpHeaders(
284+
Map.of(
285+
"webhook-id",
286+
"7a2486b3-31cf-4bd3-a460-df8845d16cd5",
287+
"webhook-signature",
288+
"v1,iayM3VaiYCEDP/CxWUFWcxUCJk2YmBDQHtHTsaHzrwo="));
289+
290+
assertThatThrownBy(() -> verifier.verify(httpHeaders, "{\"greetings\": \"Hello World\"}"))
291+
.isInstanceOf(WebhookSignatureVerificationException.class)
292+
.hasMessageContaining("No value found for header <webhook-timestamp>");
293+
}
294+
295+
@Test
296+
@DisplayName("Verify blank timestamp")
297+
void test14() {
298+
WebhookSignatureVerifier verifier =
299+
WebhookSignatureVerifier.builder("whsec_b6Ovv5eS7H5seJrGSStBYDivs8v2/KrFjfMaVZYsi7w=")
300+
.clock(Clock.fixed(Instant.ofEpochSecond(1737987215), ZoneId.systemDefault()))
301+
.build();
302+
HttpHeaders httpHeaders =
303+
createHttpHeaders(
304+
Map.of(
305+
"webhook-id",
306+
"7a2486b3-31cf-4bd3-a460-df8845d16cd5",
307+
"webhook-timestamp",
308+
"",
309+
"webhook-signature",
310+
"v1,iayM3VaiYCEDP/CxWUFWcxUCJk2YmBDQHtHTsaHzrwo="));
311+
312+
assertThatThrownBy(() -> verifier.verify(httpHeaders, "{\"greetings\": \"Hello World\"}"))
313+
.isInstanceOf(WebhookSignatureVerificationException.class)
314+
.hasMessageContaining("No value found for header <webhook-timestamp>");
315+
}
316+
317+
@Test
318+
@DisplayName("Verify unparseable timestamp")
319+
void test15() {
320+
WebhookSignatureVerifier verifier =
321+
WebhookSignatureVerifier.builder("whsec_b6Ovv5eS7H5seJrGSStBYDivs8v2/KrFjfMaVZYsi7w=")
322+
.clock(Clock.fixed(Instant.ofEpochSecond(1737987215), ZoneId.systemDefault()))
323+
.build();
324+
HttpHeaders httpHeaders =
325+
createHttpHeaders(
326+
Map.of(
327+
"webhook-id",
328+
"7a2486b3-31cf-4bd3-a460-df8845d16cd5",
329+
"webhook-timestamp",
330+
"yo",
331+
"webhook-signature",
332+
"v1,iayM3VaiYCEDP/CxWUFWcxUCJk2YmBDQHtHTsaHzrwo="));
333+
334+
assertThatThrownBy(() -> verifier.verify(httpHeaders, "{\"greetings\": \"Hello World\"}"))
335+
.isInstanceOf(WebhookSignatureVerificationException.class)
336+
.hasMessageContaining("Cannot parse timestamp <yo>");
337+
}
338+
339+
@Test
340+
@DisplayName("Verify too old timestamp")
341+
void test16() {
342+
WebhookSignatureVerifier verifier =
343+
WebhookSignatureVerifier.builder("whsec_b6Ovv5eS7H5seJrGSStBYDivs8v2/KrFjfMaVZYsi7w=")
344+
.clock(
345+
Clock.fixed(
346+
Instant.ofEpochSecond(1737987215 + Duration.ofMinutes(10).toSeconds()),
347+
ZoneId.systemDefault()))
348+
.build();
349+
HttpHeaders httpHeaders =
350+
createHttpHeaders(
351+
Map.of(
352+
"webhook-id",
353+
"7a2486b3-31cf-4bd3-a460-df8845d16cd5",
354+
"webhook-timestamp",
355+
String.valueOf(1737987215),
356+
"webhook-signature",
357+
"v1,iayM3VaiYCEDP/CxWUFWcxUCJk2YmBDQHtHTsaHzrwo="));
358+
359+
assertThatThrownBy(() -> verifier.verify(httpHeaders, "{\"greetings\": \"Hello World\"}"))
360+
.isInstanceOf(WebhookSignatureVerificationException.class)
361+
.hasMessageContaining(
362+
"Message timestamp <1737987215 seconds> is too old compared to the current timestamp <1737987815 seconds>");
363+
}
364+
365+
private HttpHeaders createHttpHeaders(Map<String, String> headers) {
366+
return HttpHeaders.of(
367+
headers.entrySet().stream()
368+
.map(entry -> Map.entry(entry.getKey(), List.of(entry.getValue())))
369+
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)),
370+
(s, s2) -> true);
371+
}
372+
}

0 commit comments

Comments
 (0)
Please sign in to comment.