Skip to content

Commit

Permalink
Support for deserializing webhook events and verifying signatures
Browse files Browse the repository at this point in the history
  • Loading branch information
ob-stripe committed Apr 28, 2017
1 parent 035cd45 commit f571260
Show file tree
Hide file tree
Showing 3 changed files with 391 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.stripe.exception;

public class SignatureVerificationException extends StripeException {

private static final long serialVersionUID = 1L;

private final String sigHeader;

public SignatureVerificationException(String message, String sigHeader) {
super(message, null, 0);
this.sigHeader = sigHeader;
}

public String getSigHeader() {
return sigHeader;
}

}
219 changes: 219 additions & 0 deletions src/main/java/com/stripe/net/Webhook.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
package com.stripe.net;

import com.stripe.exception.SignatureVerificationException;
import com.stripe.model.Event;
import com.stripe.model.StripeObject;

import java.util.ArrayList;
import java.util.List;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public final class Webhook {
private static final long DEFAULT_TOLERANCE = 300;

/**
* Returns an Event instance using the provided JSON payload. Throws a
* JsonSyntaxException if the payload is not valid JSON, and a
* SignatureVerificationException if the signature verification fails for
* any reason.
*
* @param payload the payload sent by Stripe.
* @param sigHeader the contents of the signature header sent by Stripe.
* @param secret secret used to generate the signature.
* @return the Event instance
* @throws SignatureVerificationException if the verification fails.
*/
public static Event constructEvent(String payload, String sigHeader, String secret) throws SignatureVerificationException {
return constructEvent(payload, sigHeader, secret, DEFAULT_TOLERANCE);
}

/**
* Returns an Event instance using the provided JSON payload. Throws a
* JsonSyntaxException if the payload is not valid JSON, and a
* SignatureVerificationException if the signature verification fails for
* any reason.
*
* @param payload the payload sent by Stripe.
* @param sigHeader the contents of the signature header sent by Stripe.
* @param secret secret used to generate the signature.
* @param tolerance maximum difference allowed between the header's
* timestamp and the current time
* @return the Event instance
* @throws SignatureVerificationException if the verification fails.
*/
public static Event constructEvent(String payload, String sigHeader, String secret, long tolerance) throws SignatureVerificationException {
Event event = StripeObject.PRETTY_PRINT_GSON.fromJson(payload, Event.class);
Signature.verifyHeader(payload, sigHeader, secret, tolerance);
return event;
}

public static final class Signature {
public static final String EXPECTED_SCHEME = "v1";

/**
* Verifies the signature header sent by Stripe. Throws a
* SignatureVerificationException if the verification fails for any reason.
*
* @param payload the payload sent by Stripe.
* @param sigHeader the contents of the signature header sent by Stripe.
* @param secret secret used to generate the signature.
* @param tolerance maximum difference allowed between the header's
* timestamp and the current time
* @throws SignatureVerificationException if the verification fails.
*/
public static boolean verifyHeader(String payload, String sigHeader, String secret, long tolerance) throws SignatureVerificationException {
// Get timestamp and signatures from header
long timestamp = getTimestamp(sigHeader);
List<String> signatures = getSignatures(sigHeader, EXPECTED_SCHEME);
if (timestamp <= 0) {
throw new SignatureVerificationException("Unable to extract timestamp and signatures from header", sigHeader);
}
if (signatures.size() == 0) {
throw new SignatureVerificationException("No signatures found with expected scheme", sigHeader);
}

// Compute expected signature
String signedPayload = String.format("%d.%s", timestamp, payload);
String expectedSignature;
try {
expectedSignature = computeSignature(signedPayload, secret);
} catch (Exception e) {
throw new SignatureVerificationException("Unable to compute signature for payload", sigHeader);
}

// Check if expected signature is found in list of header's signatures
Boolean signatureFound = false;
for (String signature : signatures) {
if (Util.secureCompare(expectedSignature, signature)) {
signatureFound = true;
break;
}
}
if (!signatureFound) {
throw new SignatureVerificationException("No signatures found matching the expected signature for payload", sigHeader);
}

// Check tolerance
if ((tolerance > 0) && (timestamp < (Util.getTimeNow() - tolerance))) {
throw new SignatureVerificationException("Timestamp outside the tolerance zone", sigHeader);
}

return true;
}

/**
* Extracts the timestamp in a signature header.
*
* @param sigHeader the signature header
* @return the timestamp contained in the header.
*/
private static long getTimestamp(String sigHeader) {
String[] items = sigHeader.split(",");

for (String item : items) {
String[] itemParts = item.split("=", 2);
if (itemParts[0].equals("t")) {
return Long.parseLong(itemParts[1]);
}
}

return -1;
}

/**
* Extracts the signatures matching a given scheme in a signature header.
*
* @param sigHeader the signature header
* @param scheme the signature scheme to look for.
* @return the list of signatures matching the provided scheme.
*/
private static List<String> getSignatures(String sigHeader, String scheme) {
List<String> signatures = new ArrayList<String>();
String[] items = sigHeader.split(",");

for (String item : items) {
String[] itemParts = item.split("=", 2);
if (itemParts[0].equals(scheme)) {
signatures.add(itemParts[1]);
}
}

return signatures;
}

/**
* Computes the signature for a given payload and secret.
*
* The current scheme used by Stripe ("v1") is HMAC/SHA-256.
*
* @param payload the payload to sign.
* @param secret the secret used to generate the signature.
* @return the signature as a string.
*/
private static String computeSignature(String payload, String secret) throws NoSuchAlgorithmException, InvalidKeyException {
return Util.computeHmacSHA256(secret, payload);
}
}

public static final class Util {
/**
* Computes the HMAC/SHA-256 code for a given key and message.
*
* @param key the key used to generate the code.
* @param message the message.
* @return the code as a string.
*/
public static String computeHmacSHA256(String key, String message) throws NoSuchAlgorithmException, InvalidKeyException {
Mac hasher = Mac.getInstance("HmacSHA256");
hasher.init(new SecretKeySpec(key.getBytes(), "HmacSHA256"));
byte[] hash = hasher.doFinal(message.getBytes());
String result = "";
for (byte b : hash) {
result += Integer.toString((b & 0xff) + 0x100, 16).substring(1);
}
return result;
}

/**
* Compares two strings for equality. The time taken is independent of the
* number of characters that match.
*
* Java actually has MessageDigest.isEqual() for this, but the
* implementation in very old Java 6 versions does not protect against
* timing attacks. Once we drop support for Java 6, we'll be able to just
* use MessageDigest.isEqual().
*
* @param a one of the strings to compare.
* @param b the other string to compare.
* @return true if the strings are equal, false otherwise.
*/
public static boolean secureCompare(String a, String b) {
byte[] digesta = a.getBytes();
byte[] digestb = b.getBytes();

if (digesta.length != digestb.length) {
return false;
}

int result = 0;
for (int i = 0; i < digesta.length; i++) {
result |= digesta[i] ^ digestb[i];
}
return result == 0;
}

/**
* Returns the current UTC timestamp in seconds.
*
* @return the timestamp as a long.
*/
public static long getTimeNow() {
long time = (long)(System.currentTimeMillis() / 1000L);
return time;
}
}
}
154 changes: 154 additions & 0 deletions src/test/java/com/stripe/net/WebhookTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package com.stripe.net;

import com.stripe.exception.SignatureVerificationException;

import com.stripe.BaseStripeTest;
import com.stripe.model.Event;
import com.stripe.net.Webhook;

import com.google.gson.JsonSyntaxException;

import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
import java.util.Map;

import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;

public class WebhookTest extends BaseStripeTest {
public static String secret = null;
public static String payload = null;

@Before
public void setUpFixtures() throws IOException {
secret = "whsec_test_secret";
payload = "{\n \"id\": \"evt_test_webhook\",\n \"object\": \"event\"\n}";
}

public String generateSigHeader() throws NoSuchAlgorithmException, InvalidKeyException {
Map<String, Object> options = new HashMap<String, Object>();
return generateSigHeader(options);
}

public String generateSigHeader(Map<String, Object> options) throws NoSuchAlgorithmException, InvalidKeyException {
long timestamp = (options.get("timestamp") != null) ? ((Long)options.get("timestamp")).longValue() : Webhook.Util.getTimeNow();
String payload = (options.get("payload") != null) ? (String)options.get("payload") : this.payload;
String secret = (options.get("secret") != null) ? (String)options.get("secret") : this.secret;
String scheme = (options.get("scheme") != null) ? (String)options.get("scheme") : Webhook.Signature.EXPECTED_SCHEME;
String signature = (String)options.get("signature");

if (signature == null) {
String payloadToSign = String.format("%d.%s", timestamp, payload);
signature = Webhook.Util.computeHmacSHA256(secret, payloadToSign);
}

String header = String.format("t=%d,%s=%s", timestamp, scheme, signature);
return header;
}

@Rule
public ExpectedException thrown = ExpectedException.none();

@Test
public void testValidJsonAndHeader() throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException {
String sigHeader = generateSigHeader();

Event event = Webhook.constructEvent(payload, sigHeader, secret);

assertEquals("evt_test_webhook", event.getId());
}

@Test(expected=JsonSyntaxException.class)
public void testInvalidJson() throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException {
String payload = "this is not valid JSON";
Map<String, Object> options = new HashMap<String, Object>();
options.put("payload", payload);
String sigHeader = generateSigHeader(options);

Webhook.constructEvent(payload, sigHeader, secret);
}

@Test(expected=SignatureVerificationException.class)
public void testValidJsonAndInvalidHeader() throws SignatureVerificationException {
String sigHeader = "bad_header";

Webhook.constructEvent(payload, sigHeader, secret);
}

@Test
public void testMalformedHeader() throws SignatureVerificationException {
String sigHeader = "i'm not even a real signature header";

thrown.expect(SignatureVerificationException.class);
thrown.expectMessage("Unable to extract timestamp and signatures from header");

Webhook.Signature.verifyHeader(payload, sigHeader, secret, 0);
}

@Test
public void testNoSignaturesWithExpectedScheme() throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException {
Map<String, Object> options = new HashMap<String, Object>();
options.put("scheme", "v0");
String sigHeader = generateSigHeader(options);

thrown.expect(SignatureVerificationException.class);
thrown.expectMessage("No signatures found with expected scheme");

Webhook.Signature.verifyHeader(payload, sigHeader, secret, 0);
}

@Test
public void testNoValidSignatureForPayload() throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException {
Map<String, Object> options = new HashMap<String, Object>();
options.put("signature", "bad_signature");
String sigHeader = generateSigHeader(options);

thrown.expect(SignatureVerificationException.class);
thrown.expectMessage("No signatures found matching the expected signature for payload");

Webhook.Signature.verifyHeader(payload, sigHeader, secret, 0);
}

@Test
public void testTimestampOutsideTolerance() throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException {
Map<String, Object> options = new HashMap<String, Object>();
options.put("timestamp", Webhook.Util.getTimeNow() - 15);
String sigHeader = generateSigHeader(options);

thrown.expect(SignatureVerificationException.class);
thrown.expectMessage("Timestamp outside the tolerance zone");

Webhook.Signature.verifyHeader(payload, sigHeader, secret, 10);
}

@Test
public void testValidHeaderAndSignature() throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException {
String sigHeader = generateSigHeader();

assertTrue(Webhook.Signature.verifyHeader(payload, sigHeader, secret, 10));
}

@Test
public void testHeaderContainsValidSignature() throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException {
String sigHeader = generateSigHeader();
sigHeader += ",v1=bad_signature";

assertTrue(Webhook.Signature.verifyHeader(payload, sigHeader, secret, 10));
}

@Test
public void testTimestampOffButNoTolerance() throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException {
Map<String, Object> options = new HashMap<String, Object>();
options.put("timestamp", Long.valueOf(12345L));
String sigHeader = generateSigHeader(options);

assertTrue(Webhook.Signature.verifyHeader(payload, sigHeader, secret, 0));
}
}

0 comments on commit f571260

Please sign in to comment.