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); + }); + } }