-
Notifications
You must be signed in to change notification settings - Fork 358
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Support for deserializing webhook events and verifying signatures
- Loading branch information
Showing
3 changed files
with
391 additions
and
0 deletions.
There are no files selected for viewing
18 changes: 18 additions & 0 deletions
18
src/main/java/com/stripe/exception/SignatureVerificationException.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | ||
} | ||
} |