Skip to content

Commit ca60b96

Browse files
authored
feat: Adds validateWebhook Function (#172)
- Validate webhook function, confirming HMAC signature - Unit tests added
1 parent 820820b commit ca60b96

File tree

9 files changed

+288
-5
lines changed

9 files changed

+288
-5
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- Adds the ability to buy a shipment with carbon offset
88
- Adds the ability to one-call-buy a shipment with carbon offset
99
- Adds the ability to re-rate a shipment with carbon offset
10+
- Adds `validateWebhook` function that returns your webhook or raises an error if there is a webhook secret mismatch
1011

1112
## v5.7.0 (2022-07-18)
1213

pom.xml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@
6363
<groupId>org.jetbrains</groupId>
6464
<artifactId>annotations</artifactId>
6565
<version>23.0.0</version>
66-
<scope>test</scope>
6766
</dependency>
6867
<dependency>
6968
<groupId>com.easypost</groupId>

src/main/java/com/easypost/model/Webhook.java

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
import com.easypost.exception.EasyPostException;
44
import com.easypost.net.EasyPostResource;
5+
import com.easypost.utils.Cryptography;
56

7+
import java.nio.charset.StandardCharsets;
8+
import java.text.Normalizer;
69
import java.util.Date;
710
import java.util.HashMap;
811
import java.util.Map;
@@ -229,4 +232,44 @@ public Webhook update(final Map<String, Object> params, final String apiKey) thr
229232
public Webhook update(final Map<String, Object> params) throws EasyPostException {
230233
return this.update(params, null);
231234
}
235+
236+
/**
237+
* Validate a webhook by comparing the HMAC signature header sent from EasyPost to your shared secret.
238+
* If the signatures do not match, an error will be raised signifying
239+
* the webhook either did not originate from EasyPost or the secrets do not match.
240+
* If the signatures do match, the `event_body` will be returned as JSON.
241+
*
242+
* @param eventBody Data to validate
243+
* @param headers Headers received from the webhook
244+
* @param webhookSecret Shared secret to use in validation
245+
* @return JSON string of the event body if the signatures match, otherwise an
246+
* error will be raised.
247+
* @throws EasyPostException when the request fails.
248+
*/
249+
public static Event validateWebhook(byte[] eventBody, Map<String, Object> headers, String webhookSecret)
250+
throws EasyPostException {
251+
252+
String providedSignature = null;
253+
try {
254+
providedSignature = headers.get("X-Hmac-Signature").toString();
255+
} catch (NullPointerException ignored) { // catch error raised if header key doesn't exist
256+
}
257+
258+
if (providedSignature != null) {
259+
String calculatedDigest =
260+
Cryptography.toHMACSHA256HexDigest(eventBody, webhookSecret, Normalizer.Form.NFKD);
261+
String calculatedSignature = "hmac-sha256-hex=" + calculatedDigest;
262+
263+
if (Cryptography.signaturesMatch(providedSignature, calculatedSignature)) {
264+
// Serialize data into a JSON string, then into an Event object
265+
String json = new String(eventBody, StandardCharsets.UTF_8);
266+
return GSON.fromJson(json, Event.class);
267+
} else {
268+
throw new EasyPostException(
269+
"Webhook received did not originate from EasyPost or had a webhook secret mismatch.");
270+
}
271+
} else {
272+
throw new EasyPostException("Webhook received does not contain an HMAC signature.");
273+
}
274+
}
232275
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package com.easypost.utils;
2+
3+
import org.apache.commons.codec.binary.Hex;
4+
import org.jetbrains.annotations.NotNull;
5+
import org.jetbrains.annotations.Nullable;
6+
7+
import javax.crypto.Mac;
8+
import javax.crypto.spec.SecretKeySpec;
9+
import java.nio.charset.StandardCharsets;
10+
import java.security.InvalidKeyException;
11+
import java.security.MessageDigest;
12+
import java.security.NoSuchAlgorithmException;
13+
import java.text.Normalizer;
14+
15+
/**
16+
* Class for various cryptography utilities.
17+
*/
18+
public abstract class Cryptography {
19+
/**
20+
* Enums for the supported HMAC algorithms.
21+
*/
22+
public enum HmacAlgorithm {
23+
MD5("HmacMD5"),
24+
SHA1("HmacSHA1"),
25+
SHA256("HmacSHA256"),
26+
SHA512("HmacSHA512");
27+
28+
private final String algorithmString;
29+
30+
/**
31+
* Constructor.
32+
*
33+
* @param algorithmString the algorithm string
34+
*/
35+
HmacAlgorithm(String algorithmString) {
36+
this.algorithmString = algorithmString;
37+
}
38+
39+
/**
40+
* Get the algorithm string.
41+
*
42+
* @return the algorithm string.
43+
*/
44+
String getAlgorithmString() {
45+
return algorithmString;
46+
}
47+
}
48+
49+
/**
50+
* Hex-encode a byte array to a string.
51+
*
52+
* @param bytes the byte array to hex-encode.
53+
* @return the hex-encoded byte array string.
54+
*/
55+
public static String hexEncodeToString(byte @NotNull [] bytes) {
56+
return new String(Hex.encodeHex(bytes));
57+
}
58+
59+
/**
60+
* Hex-encode a byte array to a char array.
61+
*
62+
* @param bytes the byte array to hex-encode.
63+
* @return the hex-encoded byte array char array.
64+
*/
65+
public static char[] hexEncode(byte @NotNull [] bytes) {
66+
return Hex.encodeHex(bytes);
67+
}
68+
69+
/**
70+
* Calculate the HMAC-SHA256 hex digest of a string.
71+
*
72+
* @param data Data to calculate hex digest for.
73+
* @param key Key to use in HMAC calculation.
74+
* @param normalizationForm {@link Normalizer.Form} to use when normalizing key. No normalization when null.
75+
* @return Hex digest of data.
76+
*/
77+
public static String toHMACSHA256HexDigest(byte @NotNull [] data, @NotNull String key,
78+
@Nullable Normalizer.Form normalizationForm) {
79+
if (normalizationForm != null) {
80+
key = Normalizer.normalize(key, normalizationForm);
81+
}
82+
83+
byte[] hmacBytes = createHMAC(data, key, HmacAlgorithm.SHA256);
84+
return hexEncodeToString(hmacBytes);
85+
}
86+
87+
/**
88+
* Calculate the HMAC-SHA256 hex digest of a string.
89+
*
90+
* @param data Data to calculate hex digest for.
91+
* @param key Key to use in HMAC calculation.
92+
* @param normalizationForm {@link Normalizer.Form} to use when normalizing key. No normalization when null.
93+
* @return Hex digest of data.
94+
*/
95+
public static String toHMACSHA256HexDigest(@NotNull String data, @NotNull String key,
96+
@Nullable Normalizer.Form normalizationForm) {
97+
byte[] dataBytes = data.getBytes();
98+
return toHMACSHA256HexDigest(dataBytes, key, normalizationForm);
99+
}
100+
101+
/**
102+
* Calculate the HMAC hex digest of a string.
103+
*
104+
* @param data Data to calculate hex digest for.
105+
* @param key Key to use in HMAC calculation.
106+
* @param algorithm {@link HmacAlgorithm} to use to calculate HMAC.
107+
* @return Hex digest of data.
108+
*/
109+
public static byte[] createHMAC(byte @NotNull [] data, @NotNull String key, @NotNull HmacAlgorithm algorithm) {
110+
// create HMAC-SHA256 generator and compute hash of data
111+
byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
112+
SecretKeySpec keyHash = new SecretKeySpec(keyBytes, algorithm.algorithmString);
113+
114+
try {
115+
Mac hmac = Mac.getInstance(algorithm.algorithmString);
116+
hmac.init(keyHash);
117+
return hmac.doFinal(data);
118+
} catch (InvalidKeyException | NoSuchAlgorithmException e) {
119+
throw new IllegalStateException("Cannot initialize Mac Generator", e);
120+
}
121+
}
122+
123+
/**
124+
* Check whether two signatures match. This is safe against timing attacks.
125+
*
126+
* @param signature1 First signature to check.
127+
* @param signature2 Second signature to check.
128+
* @return True if signatures match, false otherwise.
129+
*/
130+
public static boolean signaturesMatch(byte @NotNull [] signature1, byte @NotNull [] signature2) {
131+
// after Java SE 6 Update 17, MessageDigest.isEqual() is safe against timing attacks.
132+
// see: https://codahale.com//a-lesson-in-timing-attacks/
133+
return MessageDigest.isEqual(signature1, signature2);
134+
}
135+
136+
/**
137+
* Check whether two signatures match. This is safe against timing attacks.
138+
*
139+
* @param signature1 First signature to check.
140+
* @param signature2 Second signature to check.
141+
* @return True if signatures match, false otherwise.
142+
*/
143+
public static boolean signaturesMatch(@NotNull String signature1, @NotNull String signature2) {
144+
byte[] signature1Bytes = signature1.getBytes(StandardCharsets.UTF_8);
145+
byte[] signature2Bytes = signature2.getBytes(StandardCharsets.UTF_8);
146+
return signaturesMatch(signature1Bytes, signature2Bytes);
147+
}
148+
}
149+
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* Utility classes for the EasyPost API Java client library.
3+
*
4+
* @author EasyPost developers
5+
* @version 1.0
6+
* @see <a href="https://www.easypost.com/docs/api.html">EasyPost API</a>
7+
* @since 1.0
8+
*/
9+
package com.easypost.utils;

src/test/eventBody.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"result":{"id":"batch_123...","object":"Batch","mode":"test","state":"created","num_shipments":0,"reference":null,"created_at":"2022-07-26T17:22:32Z","updated_at":"2022-07-26T17:22:32Z","scan_form":null,"shipments":[],"status":{"created":0,"queued_for_purchase":0,"creation_failed":0,"postage_purchased":0,"postage_purchase_failed":0},"pickup":null,"label_url":null},"description":"batch.created","mode":"test","previous_attributes":null,"completed_urls":null,"user_id":"user_123...","status":"pending","object":"Event","id":"evt_123..."}

src/test/java/com/easypost/Fixture.java

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,17 @@
33
import com.easypost.exception.EasyPostException;
44
import com.easypost.model.Shipment;
55

6+
import java.io.IOException;
7+
import java.nio.charset.StandardCharsets;
8+
import java.nio.file.Files;
9+
import java.nio.file.Paths;
610
import java.util.ArrayList;
711
import java.util.HashMap;
812
import java.util.List;
913
import java.util.Map;
1014

15+
import static com.easypost.TestUtils.getSourceFileDirectory;
16+
1117
public abstract class Fixture {
1218
public static final int PAGE_SIZE = 5;
1319

@@ -107,8 +113,8 @@ public static String usps() {
107113
*/
108114
public static String uspsCarrierAccountID() {
109115
// Fallback to the EasyPost Java Client Library Test User USPS carrier account
110-
return System.getenv("USPS_CARRIER_ACCOUNT_ID") != null ? System.getenv("USPS_CARRIER_ACCOUNT_ID")
111-
: "ca_f09befdb2e9c410e95c7622ea912c18c";
116+
return System.getenv("USPS_CARRIER_ACCOUNT_ID") != null ? System.getenv("USPS_CARRIER_ACCOUNT_ID") :
117+
"ca_f09befdb2e9c410e95c7622ea912c18c";
112118
}
113119

114120
/**
@@ -489,4 +495,23 @@ public static Map<String, Object> oneCallBuyCarbonOffsetShipment() {
489495

490496
return oneCallBuyShipment;
491497
}
498+
499+
/**
500+
* Get the fixture for a webhook event body as a byte array.
501+
*
502+
* @return The webhook event body fixture as a byte array.
503+
*/
504+
public static byte[] eventBody() {
505+
String relativeFilePath = "src/test/eventBody.json";
506+
String fullFilePath = Paths.get(getSourceFileDirectory(), relativeFilePath).toString();
507+
byte[] data = null;
508+
509+
try {
510+
data = Files.readAllLines(Paths.get(fullFilePath), StandardCharsets.UTF_8).get(0).getBytes();
511+
} catch (IOException error) {
512+
error.printStackTrace();
513+
}
514+
515+
return data;
516+
}
492517
}

src/test/java/com/easypost/TestUtils.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ public enum ApiKey {
4949
*
5050
* @return The directory where the program is currently executing
5151
*/
52-
private static String getSourceFileDirectory() {
52+
public static String getSourceFileDirectory() {
5353
try {
5454
return Paths.get("").toAbsolutePath().toString();
5555
} catch (Exception e) {

src/test/java/com/easypost/WebhookTest.java

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.easypost;
22

33
import com.easypost.exception.EasyPostException;
4+
import com.easypost.model.Event;
45
import com.easypost.model.Webhook;
56
import com.easypost.model.WebhookCollection;
67
import org.junit.jupiter.api.AfterEach;
@@ -16,9 +17,10 @@
1617
import static org.assertj.core.api.Assertions.assertThat;
1718
import static org.junit.jupiter.api.Assertions.assertEquals;
1819
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
20+
import static org.junit.jupiter.api.Assertions.assertThrows;
1921
import static org.junit.jupiter.api.Assertions.assertTrue;
2022

21-
@TestMethodOrder (MethodOrderer.OrderAnnotation.class)
23+
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
2224
public final class WebhookTest {
2325
private static String testWebhookId = null;
2426
private static TestUtils.VCR vcr;
@@ -144,4 +146,58 @@ public void testDelete() throws EasyPostException {
144146

145147
testWebhookId = null; // need to disable post-test deletion for test to work
146148
}
149+
150+
/**
151+
* Test validating a webhook.
152+
*
153+
* @throws EasyPostException when the request fails.
154+
*/
155+
@Test
156+
public void testValidateWebhook() throws EasyPostException {
157+
String webhookSecret = "sécret";
158+
Map<String, Object> headers = new HashMap<String, Object>() {
159+
{
160+
put("X-Hmac-Signature",
161+
"hmac-sha256-hex=e93977c8ccb20363d51a62b3fe1fc402b7829be1152da9e88cf9e8d07115a46b");
162+
}
163+
};
164+
165+
Event event = Webhook.validateWebhook(Fixture.eventBody(), headers, webhookSecret);
166+
167+
assertEquals("batch.created", event.getDescription());
168+
}
169+
170+
/**
171+
* Test validating a webhook.
172+
*/
173+
@Test
174+
public void testValidateWebhookInvalidSecret() {
175+
String webhookSecret = "invalid_secret";
176+
Map<String, Object> headers = new HashMap<String, Object>() {
177+
{
178+
put("X-Hmac-Signature", "some-signature");
179+
}
180+
};
181+
182+
assertThrows(EasyPostException.class, () -> {
183+
Webhook.validateWebhook(Fixture.eventBody(), headers, webhookSecret);
184+
});
185+
}
186+
187+
/**
188+
* Test validating a webhook.
189+
*/
190+
@Test
191+
public void testValidateWebhookMissingSecret() {
192+
String webhookSecret = "123";
193+
Map<String, Object> headers = new HashMap<String, Object>() {
194+
{
195+
put("some-header", "some-value");
196+
}
197+
};
198+
199+
assertThrows(EasyPostException.class, () -> {
200+
Webhook.validateWebhook(Fixture.eventBody(), headers, webhookSecret);
201+
});
202+
}
147203
}

0 commit comments

Comments
 (0)