diff --git a/CHANGELOG.md b/CHANGELOG.md
index 29ffe275d..f45b2e8ca 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@
- Adds the ability to buy a shipment with carbon offset
- Adds the ability to one-call-buy a shipment with carbon offset
- Adds the ability to re-rate a shipment with carbon offset
+- Adds `validateWebhook` function that returns your webhook or raises an error if there is a webhook secret mismatch
## v5.7.0 (2022-07-18)
diff --git a/pom.xml b/pom.xml
index ba901af85..84c02dac5 100644
--- a/pom.xml
+++ b/pom.xml
@@ -63,7 +63,6 @@
org.jetbrains
annotations
23.0.0
- test
com.easypost
diff --git a/src/main/java/com/easypost/model/Webhook.java b/src/main/java/com/easypost/model/Webhook.java
index 0bbd5e355..8c54bb299 100644
--- a/src/main/java/com/easypost/model/Webhook.java
+++ b/src/main/java/com/easypost/model/Webhook.java
@@ -2,7 +2,10 @@
import com.easypost.exception.EasyPostException;
import com.easypost.net.EasyPostResource;
+import com.easypost.utils.Cryptography;
+import java.nio.charset.StandardCharsets;
+import java.text.Normalizer;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@@ -229,4 +232,44 @@ public Webhook update(final Map params, final String apiKey) thr
public Webhook update(final Map params) throws EasyPostException {
return this.update(params, null);
}
+
+ /**
+ * Validate a webhook by comparing the HMAC signature header sent from EasyPost to your shared secret.
+ * If the signatures do not match, an error will be raised signifying
+ * the webhook either did not originate from EasyPost or the secrets do not match.
+ * If the signatures do match, the `event_body` will be returned as JSON.
+ *
+ * @param eventBody Data to validate
+ * @param headers Headers received from the webhook
+ * @param webhookSecret Shared secret to use in validation
+ * @return JSON string of the event body if the signatures match, otherwise an
+ * error will be raised.
+ * @throws EasyPostException when the request fails.
+ */
+ public static Event validateWebhook(byte[] eventBody, Map headers, String webhookSecret)
+ throws EasyPostException {
+
+ String providedSignature = null;
+ try {
+ providedSignature = headers.get("X-Hmac-Signature").toString();
+ } catch (NullPointerException ignored) { // catch error raised if header key doesn't exist
+ }
+
+ if (providedSignature != null) {
+ String calculatedDigest =
+ Cryptography.toHMACSHA256HexDigest(eventBody, webhookSecret, Normalizer.Form.NFKD);
+ String calculatedSignature = "hmac-sha256-hex=" + calculatedDigest;
+
+ if (Cryptography.signaturesMatch(providedSignature, calculatedSignature)) {
+ // Serialize data into a JSON string, then into an Event object
+ String json = new String(eventBody, StandardCharsets.UTF_8);
+ return GSON.fromJson(json, Event.class);
+ } else {
+ throw new EasyPostException(
+ "Webhook received did not originate from EasyPost or had a webhook secret mismatch.");
+ }
+ } else {
+ throw new EasyPostException("Webhook received does not contain an HMAC signature.");
+ }
+ }
}
diff --git a/src/main/java/com/easypost/utils/Cryptography.java b/src/main/java/com/easypost/utils/Cryptography.java
new file mode 100644
index 000000000..6f3536c64
--- /dev/null
+++ b/src/main/java/com/easypost/utils/Cryptography.java
@@ -0,0 +1,149 @@
+package com.easypost.utils;
+
+import org.apache.commons.codec.binary.Hex;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.security.InvalidKeyException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.text.Normalizer;
+
+/**
+ * Class for various cryptography utilities.
+ */
+public abstract class Cryptography {
+ /**
+ * Enums for the supported HMAC algorithms.
+ */
+ public enum HmacAlgorithm {
+ MD5("HmacMD5"),
+ SHA1("HmacSHA1"),
+ SHA256("HmacSHA256"),
+ SHA512("HmacSHA512");
+
+ private final String algorithmString;
+
+ /**
+ * Constructor.
+ *
+ * @param algorithmString the algorithm string
+ */
+ HmacAlgorithm(String algorithmString) {
+ this.algorithmString = algorithmString;
+ }
+
+ /**
+ * Get the algorithm string.
+ *
+ * @return the algorithm string.
+ */
+ String getAlgorithmString() {
+ return algorithmString;
+ }
+ }
+
+ /**
+ * Hex-encode a byte array to a string.
+ *
+ * @param bytes the byte array to hex-encode.
+ * @return the hex-encoded byte array string.
+ */
+ public static String hexEncodeToString(byte @NotNull [] bytes) {
+ return new String(Hex.encodeHex(bytes));
+ }
+
+ /**
+ * Hex-encode a byte array to a char array.
+ *
+ * @param bytes the byte array to hex-encode.
+ * @return the hex-encoded byte array char array.
+ */
+ public static char[] hexEncode(byte @NotNull [] bytes) {
+ return Hex.encodeHex(bytes);
+ }
+
+ /**
+ * Calculate the HMAC-SHA256 hex digest of a string.
+ *
+ * @param data Data to calculate hex digest for.
+ * @param key Key to use in HMAC calculation.
+ * @param normalizationForm {@link Normalizer.Form} to use when normalizing key. No normalization when null.
+ * @return Hex digest of data.
+ */
+ public static String toHMACSHA256HexDigest(byte @NotNull [] data, @NotNull String key,
+ @Nullable Normalizer.Form normalizationForm) {
+ if (normalizationForm != null) {
+ key = Normalizer.normalize(key, normalizationForm);
+ }
+
+ byte[] hmacBytes = createHMAC(data, key, HmacAlgorithm.SHA256);
+ return hexEncodeToString(hmacBytes);
+ }
+
+ /**
+ * Calculate the HMAC-SHA256 hex digest of a string.
+ *
+ * @param data Data to calculate hex digest for.
+ * @param key Key to use in HMAC calculation.
+ * @param normalizationForm {@link Normalizer.Form} to use when normalizing key. No normalization when null.
+ * @return Hex digest of data.
+ */
+ public static String toHMACSHA256HexDigest(@NotNull String data, @NotNull String key,
+ @Nullable Normalizer.Form normalizationForm) {
+ byte[] dataBytes = data.getBytes();
+ return toHMACSHA256HexDigest(dataBytes, key, normalizationForm);
+ }
+
+ /**
+ * Calculate the HMAC hex digest of a string.
+ *
+ * @param data Data to calculate hex digest for.
+ * @param key Key to use in HMAC calculation.
+ * @param algorithm {@link HmacAlgorithm} to use to calculate HMAC.
+ * @return Hex digest of data.
+ */
+ public static byte[] createHMAC(byte @NotNull [] data, @NotNull String key, @NotNull HmacAlgorithm algorithm) {
+ // create HMAC-SHA256 generator and compute hash of data
+ byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
+ SecretKeySpec keyHash = new SecretKeySpec(keyBytes, algorithm.algorithmString);
+
+ try {
+ Mac hmac = Mac.getInstance(algorithm.algorithmString);
+ hmac.init(keyHash);
+ return hmac.doFinal(data);
+ } catch (InvalidKeyException | NoSuchAlgorithmException e) {
+ throw new IllegalStateException("Cannot initialize Mac Generator", e);
+ }
+ }
+
+ /**
+ * Check whether two signatures match. This is safe against timing attacks.
+ *
+ * @param signature1 First signature to check.
+ * @param signature2 Second signature to check.
+ * @return True if signatures match, false otherwise.
+ */
+ public static boolean signaturesMatch(byte @NotNull [] signature1, byte @NotNull [] signature2) {
+ // after Java SE 6 Update 17, MessageDigest.isEqual() is safe against timing attacks.
+ // see: https://codahale.com//a-lesson-in-timing-attacks/
+ return MessageDigest.isEqual(signature1, signature2);
+ }
+
+ /**
+ * Check whether two signatures match. This is safe against timing attacks.
+ *
+ * @param signature1 First signature to check.
+ * @param signature2 Second signature to check.
+ * @return True if signatures match, false otherwise.
+ */
+ public static boolean signaturesMatch(@NotNull String signature1, @NotNull String signature2) {
+ byte[] signature1Bytes = signature1.getBytes(StandardCharsets.UTF_8);
+ byte[] signature2Bytes = signature2.getBytes(StandardCharsets.UTF_8);
+ return signaturesMatch(signature1Bytes, signature2Bytes);
+ }
+}
+
diff --git a/src/main/java/com/easypost/utils/package-info.java b/src/main/java/com/easypost/utils/package-info.java
new file mode 100644
index 000000000..9adfdf5c9
--- /dev/null
+++ b/src/main/java/com/easypost/utils/package-info.java
@@ -0,0 +1,9 @@
+/**
+ * Utility classes for the EasyPost API Java client library.
+ *
+ * @author EasyPost developers
+ * @version 1.0
+ * @see EasyPost API
+ * @since 1.0
+ */
+package com.easypost.utils;
diff --git a/src/test/eventBody.json b/src/test/eventBody.json
new file mode 100644
index 000000000..97c3b2aa7
--- /dev/null
+++ b/src/test/eventBody.json
@@ -0,0 +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..."}
diff --git a/src/test/java/com/easypost/Fixture.java b/src/test/java/com/easypost/Fixture.java
index 072604a2e..ae66dca4b 100644
--- a/src/test/java/com/easypost/Fixture.java
+++ b/src/test/java/com/easypost/Fixture.java
@@ -3,11 +3,17 @@
import com.easypost.exception.EasyPostException;
import com.easypost.model.Shipment;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import static com.easypost.TestUtils.getSourceFileDirectory;
+
public abstract class Fixture {
public static final int PAGE_SIZE = 5;
@@ -107,8 +113,8 @@ public static String usps() {
*/
public static String uspsCarrierAccountID() {
// Fallback to the EasyPost Java Client Library Test User USPS carrier account
- return System.getenv("USPS_CARRIER_ACCOUNT_ID") != null ? System.getenv("USPS_CARRIER_ACCOUNT_ID")
- : "ca_f09befdb2e9c410e95c7622ea912c18c";
+ return System.getenv("USPS_CARRIER_ACCOUNT_ID") != null ? System.getenv("USPS_CARRIER_ACCOUNT_ID") :
+ "ca_f09befdb2e9c410e95c7622ea912c18c";
}
/**
@@ -489,4 +495,23 @@ public static Map oneCallBuyCarbonOffsetShipment() {
return oneCallBuyShipment;
}
+
+ /**
+ * Get the fixture for a webhook event body as a byte array.
+ *
+ * @return The webhook event body fixture as a byte array.
+ */
+ public static byte[] eventBody() {
+ String relativeFilePath = "src/test/eventBody.json";
+ String fullFilePath = Paths.get(getSourceFileDirectory(), relativeFilePath).toString();
+ byte[] data = null;
+
+ try {
+ data = Files.readAllLines(Paths.get(fullFilePath), StandardCharsets.UTF_8).get(0).getBytes();
+ } catch (IOException error) {
+ error.printStackTrace();
+ }
+
+ return data;
+ }
}
diff --git a/src/test/java/com/easypost/TestUtils.java b/src/test/java/com/easypost/TestUtils.java
index 204a94c25..8b0be1c6b 100644
--- a/src/test/java/com/easypost/TestUtils.java
+++ b/src/test/java/com/easypost/TestUtils.java
@@ -49,7 +49,7 @@ public enum ApiKey {
*
* @return The directory where the program is currently executing
*/
- private static String getSourceFileDirectory() {
+ public static String getSourceFileDirectory() {
try {
return Paths.get("").toAbsolutePath().toString();
} catch (Exception e) {
diff --git a/src/test/java/com/easypost/WebhookTest.java b/src/test/java/com/easypost/WebhookTest.java
index ed6a500d7..f9f46d24d 100644
--- a/src/test/java/com/easypost/WebhookTest.java
+++ b/src/test/java/com/easypost/WebhookTest.java
@@ -1,6 +1,7 @@
package com.easypost;
import com.easypost.exception.EasyPostException;
+import com.easypost.model.Event;
import com.easypost.model.Webhook;
import com.easypost.model.WebhookCollection;
import org.junit.jupiter.api.AfterEach;
@@ -16,9 +17,10 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
-@TestMethodOrder (MethodOrderer.OrderAnnotation.class)
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public final class WebhookTest {
private static String testWebhookId = null;
private static TestUtils.VCR vcr;
@@ -144,4 +146,58 @@ public void testDelete() throws EasyPostException {
testWebhookId = null; // need to disable post-test deletion for test to work
}
+
+ /**
+ * Test validating a webhook.
+ *
+ * @throws EasyPostException when the request fails.
+ */
+ @Test
+ public void testValidateWebhook() throws EasyPostException {
+ String webhookSecret = "sécret";
+ Map headers = new HashMap() {
+ {
+ put("X-Hmac-Signature",
+ "hmac-sha256-hex=e93977c8ccb20363d51a62b3fe1fc402b7829be1152da9e88cf9e8d07115a46b");
+ }
+ };
+
+ Event event = Webhook.validateWebhook(Fixture.eventBody(), headers, webhookSecret);
+
+ assertEquals("batch.created", event.getDescription());
+ }
+
+ /**
+ * Test validating a webhook.
+ */
+ @Test
+ public void testValidateWebhookInvalidSecret() {
+ String webhookSecret = "invalid_secret";
+ Map headers = new HashMap() {
+ {
+ put("X-Hmac-Signature", "some-signature");
+ }
+ };
+
+ assertThrows(EasyPostException.class, () -> {
+ Webhook.validateWebhook(Fixture.eventBody(), headers, webhookSecret);
+ });
+ }
+
+ /**
+ * Test validating a webhook.
+ */
+ @Test
+ public void testValidateWebhookMissingSecret() {
+ String webhookSecret = "123";
+ Map headers = new HashMap() {
+ {
+ put("some-header", "some-value");
+ }
+ };
+
+ assertThrows(EasyPostException.class, () -> {
+ Webhook.validateWebhook(Fixture.eventBody(), headers, webhookSecret);
+ });
+ }
}