Skip to content

Commit 850208e

Browse files
committed
Init
1 parent 58d90ab commit 850208e

11 files changed

+872
-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+
}

0 commit comments

Comments
 (0)