Skip to content

Commit

Permalink
feat: Adds validateWebhook Function (#172)
Browse files Browse the repository at this point in the history
- Validate webhook function, confirming HMAC signature
- Unit tests added
  • Loading branch information
Justintime50 authored Aug 2, 2022
1 parent 820820b commit ca60b96
Show file tree
Hide file tree
Showing 9 changed files with 288 additions and 5 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
1 change: 0 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@
<groupId>org.jetbrains</groupId>
<artifactId>annotations</artifactId>
<version>23.0.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.easypost</groupId>
Expand Down
43 changes: 43 additions & 0 deletions src/main/java/com/easypost/model/Webhook.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -229,4 +232,44 @@ public Webhook update(final Map<String, Object> params, final String apiKey) thr
public Webhook update(final Map<String, Object> 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<String, Object> 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.");
}
}
}
149 changes: 149 additions & 0 deletions src/main/java/com/easypost/utils/Cryptography.java
Original file line number Diff line number Diff line change
@@ -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);
}
}

9 changes: 9 additions & 0 deletions src/main/java/com/easypost/utils/package-info.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Utility classes for the EasyPost API Java client library.
*
* @author EasyPost developers
* @version 1.0
* @see <a href="https://www.easypost.com/docs/api.html">EasyPost API</a>
* @since 1.0
*/
package com.easypost.utils;
1 change: 1 addition & 0 deletions src/test/eventBody.json
Original file line number Diff line number Diff line change
@@ -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..."}
29 changes: 27 additions & 2 deletions src/test/java/com/easypost/Fixture.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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";
}

/**
Expand Down Expand Up @@ -489,4 +495,23 @@ public static Map<String, Object> 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;
}
}
2 changes: 1 addition & 1 deletion src/test/java/com/easypost/TestUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
58 changes: 57 additions & 1 deletion src/test/java/com/easypost/WebhookTest.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<String, Object> headers = new HashMap<String, Object>() {
{
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<String, Object> headers = new HashMap<String, Object>() {
{
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<String, Object> headers = new HashMap<String, Object>() {
{
put("some-header", "some-value");
}
};

assertThrows(EasyPostException.class, () -> {
Webhook.validateWebhook(Fixture.eventBody(), headers, webhookSecret);
});
}
}

0 comments on commit ca60b96

Please sign in to comment.