Skip to content

Commit 3ddf448

Browse files
committed
Init
1 parent 58d90ab commit 3ddf448

9 files changed

+426
-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,81 @@
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 String SCHEME_ID = "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 void verify(String messageId, long timestamp, String payload, Signature signature)
42+
throws SignatureNotSupportedException, WebhookSignatureVerificationException {
43+
try {
44+
doVerify(messageId, timestamp, payload, signature);
45+
} catch (NoSuchAlgorithmException
46+
| InvalidKeyException
47+
| SignatureException
48+
| InvalidKeySpecException e) {
49+
throw new WebhookSignatureVerificationException(e);
50+
}
51+
}
52+
53+
private void doVerify(String messageId, long timestamp, String payload, Signature signature)
54+
throws SignatureNotSupportedException,
55+
NoSuchAlgorithmException,
56+
InvalidKeyException,
57+
SignatureException,
58+
InvalidKeySpecException {
59+
60+
if (!SCHEME_ID.equals(signature.schemeId())) {
61+
throw new SignatureNotSupportedException(
62+
"%s does not support version <%s>".formatted(this, signature.schemeId()));
63+
}
64+
65+
java.security.Signature signatureApi = java.security.Signature.getInstance(ALGORITHM);
66+
67+
java.security.PublicKey publicKey =
68+
KeyFactory.getInstance(ALGORITHM)
69+
.generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(value), ALGORITHM));
70+
71+
String signedContent = "%s.%s.%s".formatted(messageId, timestamp, payload);
72+
signatureApi.initVerify(publicKey);
73+
signatureApi.update(signedContent.getBytes(StandardCharsets.UTF_8));
74+
75+
if (signatureApi.verify(Base64.getDecoder().decode(signature.base64EncodedSignature()))) {
76+
return;
77+
}
78+
79+
throw new SignatureNotSupportedException("The signature is not valid");
80+
}
81+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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 String SCHEME_ID = "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 void verify(String messageId, long timestamp, String payload, Signature signature)
40+
throws SignatureNotSupportedException, WebhookSignatureVerificationException {
41+
42+
if (!SCHEME_ID.equals(signature.schemeId())) {
43+
throw new SignatureNotSupportedException(
44+
"%s does not support version <%s>".formatted(this, signature.schemeId()));
45+
}
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(signature.base64EncodedSignature())) {
55+
return;
56+
}
57+
58+
throw new SignatureNotSupportedException(
59+
"The provided signature does not match the expected signature.");
60+
}
61+
62+
private String sign(String messageId, long timestamp, String payload)
63+
throws NoSuchAlgorithmException, InvalidKeyException {
64+
String contentToSign = "%s.%s.%s".formatted(messageId, timestamp, payload);
65+
Mac sha512Hmac = Mac.getInstance(ALGORITHM);
66+
SecretKeySpec keySpec = new SecretKeySpec(value, ALGORITHM);
67+
sha512Hmac.init(keySpec);
68+
byte[] macData = sha512Hmac.doFinal(contentToSign.getBytes(StandardCharsets.UTF_8));
69+
return Base64.getEncoder().encodeToString(macData);
70+
}
71+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package com.cosium.standard_webhooks_consumer;
2+
3+
import java.net.http.HttpHeaders;
4+
import java.util.List;
5+
import java.util.Optional;
6+
import java.util.stream.Stream;
7+
import org.slf4j.Logger;
8+
import org.slf4j.LoggerFactory;
9+
10+
/**
11+
* @author Réda Housni Alaoui
12+
*/
13+
record Signature(String schemeId, String base64EncodedSignature) {
14+
15+
private static final Logger LOGGER = LoggerFactory.getLogger(Signature.class);
16+
17+
private static final String MESSAGE_SIGNATURE_HEADER_NAME = "webhook-signature";
18+
19+
Signature {
20+
if (schemeId == null || schemeId.isBlank()) {
21+
throw new IllegalArgumentException("Version cannot be null or blank");
22+
}
23+
if (base64EncodedSignature == null || base64EncodedSignature.isBlank()) {
24+
throw new IllegalArgumentException("Base64EncodedSignature cannot be null or blank");
25+
}
26+
}
27+
28+
public static List<Signature> parseAtLeastOne(HttpHeaders headers)
29+
throws WebhookSignatureVerificationException {
30+
String messageSignatureHeaderValue =
31+
headers.firstValue(MESSAGE_SIGNATURE_HEADER_NAME).orElse(null);
32+
if (messageSignatureHeaderValue == null || messageSignatureHeaderValue.isBlank()) {
33+
throw new WebhookSignatureVerificationException(
34+
"No value found for header <%s>".formatted(MESSAGE_SIGNATURE_HEADER_NAME));
35+
}
36+
37+
List<Signature> signatures =
38+
Stream.of(messageSignatureHeaderValue.split(" "))
39+
.map(Signature::createSignature)
40+
.filter(Optional::isPresent)
41+
.map(Optional::get)
42+
.toList();
43+
44+
if (signatures.isEmpty()) {
45+
throw new WebhookSignatureVerificationException(
46+
"No signature found for header value <%s>".formatted(messageSignatureHeaderValue));
47+
}
48+
49+
return signatures;
50+
}
51+
52+
private static Optional<Signature> createSignature(String messageSignature) {
53+
if (messageSignature == null || messageSignature.isBlank()) {
54+
return Optional.empty();
55+
}
56+
String[] signatureParts = messageSignature.split(",");
57+
if (signatureParts.length != 2) {
58+
return Optional.empty();
59+
}
60+
61+
Signature signature;
62+
try {
63+
signature = new Signature(signatureParts[0], signatureParts[1]);
64+
} catch (RuntimeException e) {
65+
LOGGER.warn(e.getMessage());
66+
return Optional.empty();
67+
}
68+
69+
return Optional.of(signature);
70+
}
71+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.cosium.standard_webhooks_consumer;
2+
3+
/**
4+
* @author Réda Housni Alaoui
5+
*/
6+
class SignatureNotSupportedException extends Exception {
7+
8+
SignatureNotSupportedException(String message) {
9+
super(message);
10+
}
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.cosium.standard_webhooks_consumer;
2+
3+
/**
4+
* @author Réda Housni Alaoui
5+
*/
6+
interface VerificationKey {
7+
8+
void verify(String messageId, long timestamp, String payload, Signature signature)
9+
throws SignatureNotSupportedException, WebhookSignatureVerificationException;
10+
}
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 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)